kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
feat: getting closer to stripe perfection <3
rodzic
353f0ff46a
commit
cd77eef88b
|
@ -11,6 +11,10 @@ import { z } from '@hono/zod-openapi'
|
||||||
|
|
||||||
import { deployments, deploymentSelectSchema } from './deployment'
|
import { deployments, deploymentSelectSchema } from './deployment'
|
||||||
import { projects, projectSelectSchema } from './project'
|
import { projects, projectSelectSchema } from './project'
|
||||||
|
import {
|
||||||
|
type StripeSubscriptionItemIdMap,
|
||||||
|
stripeSubscriptionItemIdMapSchema
|
||||||
|
} from './types'
|
||||||
import { users, userSelectSchema } from './user'
|
import { users, userSelectSchema } from './user'
|
||||||
import {
|
import {
|
||||||
createInsertSchema,
|
createInsertSchema,
|
||||||
|
@ -89,9 +93,9 @@ export const consumers = pgTable(
|
||||||
// Main Stripe Subscription id
|
// Main Stripe Subscription id
|
||||||
_stripeSubscriptionId: stripeId(),
|
_stripeSubscriptionId: stripeId(),
|
||||||
|
|
||||||
// [lineItemSlug: string]: string
|
// [pricingPlanLineItemSlug: string]: string
|
||||||
_stripeSubscriptionLineItemIdMap: jsonb()
|
_stripeSubscriptionItemIdMap: jsonb()
|
||||||
.$type<Record<string, string>>()
|
.$type<StripeSubscriptionItemIdMap>()
|
||||||
.default({})
|
.default({})
|
||||||
.notNull(),
|
.notNull(),
|
||||||
|
|
||||||
|
@ -132,7 +136,7 @@ export const consumerRelationsSchema: z.ZodType<ConsumerRelationFields> =
|
||||||
z.enum(['user', 'project', 'deployment'])
|
z.enum(['user', 'project', 'deployment'])
|
||||||
|
|
||||||
export const consumerSelectSchema = createSelectSchema(consumers, {
|
export const consumerSelectSchema = createSelectSchema(consumers, {
|
||||||
_stripeSubscriptionLineItemIdMap: z.record(z.string(), z.string()),
|
_stripeSubscriptionItemIdMap: stripeSubscriptionItemIdMapSchema,
|
||||||
|
|
||||||
deploymentId: (schema) =>
|
deploymentId: (schema) =>
|
||||||
schema.refine((id) => validators.deploymentId(id), {
|
schema.refine((id) => validators.deploymentId(id), {
|
||||||
|
@ -146,7 +150,7 @@ export const consumerSelectSchema = createSelectSchema(consumers, {
|
||||||
})
|
})
|
||||||
.omit({
|
.omit({
|
||||||
_stripeSubscriptionId: true,
|
_stripeSubscriptionId: true,
|
||||||
_stripeSubscriptionLineItemIdMap: true,
|
_stripeSubscriptionItemIdMap: true,
|
||||||
_stripeCustomerId: true
|
_stripeCustomerId: true
|
||||||
})
|
})
|
||||||
.extend({
|
.extend({
|
||||||
|
|
|
@ -12,10 +12,10 @@ import { z } from '@hono/zod-openapi'
|
||||||
import { projects } from './project'
|
import { projects } from './project'
|
||||||
import { teams, teamSelectSchema } from './team'
|
import { teams, teamSelectSchema } from './team'
|
||||||
import {
|
import {
|
||||||
|
type PricingPlanList,
|
||||||
|
pricingPlanListSchema
|
||||||
// type Coupon,
|
// type Coupon,
|
||||||
// couponSchema,
|
// couponSchema,
|
||||||
type PricingPlanMap,
|
|
||||||
pricingPlanMapSchema
|
|
||||||
} from './types'
|
} from './types'
|
||||||
import { users, userSelectSchema } from './user'
|
import { users, userSelectSchema } from './user'
|
||||||
import {
|
import {
|
||||||
|
@ -64,8 +64,8 @@ export const deployments = pgTable(
|
||||||
// Backend API URL
|
// Backend API URL
|
||||||
_url: text().notNull(),
|
_url: text().notNull(),
|
||||||
|
|
||||||
// Record<string, PricingPlan>
|
// Array<PricingPlan>
|
||||||
pricingPlanMap: jsonb().$type<PricingPlanMap>().notNull()
|
pricingPlans: jsonb().$type<PricingPlanList>().notNull()
|
||||||
|
|
||||||
// coupons: jsonb().$type<Coupon[]>().default([]).notNull()
|
// coupons: jsonb().$type<Coupon[]>().default([]).notNull()
|
||||||
},
|
},
|
||||||
|
@ -107,7 +107,7 @@ export const deploymentSelectSchema = createSelectSchema(deployments, {
|
||||||
// build: z.object({}),
|
// build: z.object({}),
|
||||||
// env: z.object({}),
|
// env: z.object({}),
|
||||||
|
|
||||||
pricingPlanMap: pricingPlanMapSchema
|
pricingPlans: pricingPlanListSchema
|
||||||
// coupons: z.array(couponSchema)
|
// coupons: z.array(couponSchema)
|
||||||
})
|
})
|
||||||
.omit({
|
.omit({
|
||||||
|
@ -145,11 +145,9 @@ export const deploymentInsertSchema = createInsertSchema(deployments, {
|
||||||
|
|
||||||
_url: (schema) => schema.url(),
|
_url: (schema) => schema.url(),
|
||||||
|
|
||||||
// TODO: should this public resource be decoupled from the internal pricing
|
pricingPlans: pricingPlanListSchema
|
||||||
// plan structure?
|
|
||||||
pricingPlanMap: pricingPlanMapSchema
|
|
||||||
|
|
||||||
// TODO
|
// TODOp
|
||||||
// coupons: z.array(couponSchema).optional()
|
// coupons: z.array(couponSchema).optional()
|
||||||
})
|
})
|
||||||
.omit({ id: true, createdAt: true, updatedAt: true })
|
.omit({ id: true, createdAt: true, updatedAt: true })
|
||||||
|
|
|
@ -95,7 +95,16 @@ export const pricingPlanLineItemHashSchema = z
|
||||||
export const pricingPlanLineItemSlugSchema = z
|
export const pricingPlanLineItemSlugSchema = z
|
||||||
.string()
|
.string()
|
||||||
.nonempty()
|
.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
|
export const stripePriceIdMapSchema = z
|
||||||
.record(pricingPlanLineItemHashSchema, z.string().describe('Stripe Price id'))
|
.record(pricingPlanLineItemHashSchema, z.string().describe('Stripe Price id'))
|
||||||
|
@ -256,7 +265,13 @@ export type PricingPlanLineItem = z.infer<typeof pricingPlanLineItemSchema>
|
||||||
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' }),
|
||||||
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.
|
* The frequency at which a subscription is billed.
|
||||||
|
@ -325,27 +340,69 @@ export const stripeProductIdMapSchema = z
|
||||||
.openapi('StripeProductIdMap')
|
.openapi('StripeProductIdMap')
|
||||||
export type StripeProductIdMap = z.infer<typeof stripeProductIdMapSchema>
|
export type StripeProductIdMap = z.infer<typeof stripeProductIdMapSchema>
|
||||||
|
|
||||||
export const pricingPlanMapSchema = z
|
export const pricingPlanListSchema = z
|
||||||
.record(
|
.array(pricingPlanSchema)
|
||||||
z
|
.nonempty({
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
'PricingPlan slug ("free", "starter-monthly", "pro-annual", etc)'
|
|
||||||
),
|
|
||||||
pricingPlanSchema
|
|
||||||
)
|
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
|
||||||
message: 'Must contain at least one PricingPlan'
|
message: 'Must contain at least one PricingPlan'
|
||||||
})
|
})
|
||||||
.describe('Map from PricingPlan slug to PricingPlan')
|
.refine(
|
||||||
export type PricingPlanMap = z.infer<typeof pricingPlanMapSchema>
|
(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<string, PricingPlanLineItem[]> =
|
||||||
|
{}
|
||||||
|
for (const pricingPlan of pricingPlans) {
|
||||||
|
for (const lineItem of pricingPlan.lineItems) {
|
||||||
|
if (!pricingPlanLineItemSlugMap[lineItem.slug]) {
|
||||||
|
pricingPlanLineItemSlugMap[lineItem.slug] = []
|
||||||
|
}
|
||||||
|
|
||||||
// TODO
|
pricingPlanLineItemSlugMap[lineItem.slug]!.push(lineItem)
|
||||||
// export const _stripeSubscriptionLineItemIdMapSchema = z
|
}
|
||||||
// .record(pricingPlanLineItemHashSchema, z.string().describe('Stripe LineItem id'))
|
}
|
||||||
// .describe('Map from internal PricingPlanLineItem **hash** to Stripe LineItem id')
|
|
||||||
// .openapi('StripeSubscriptionLineItemMap')
|
for (const lineItems of Object.values(pricingPlanLineItemSlugMap)) {
|
||||||
// export type StripeSubscriptionLineItemMap = z.infer<typeof stripeSubscriptionLineItemMapSchema>
|
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<typeof pricingPlanListSchema>
|
||||||
|
|
||||||
|
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
|
// export const couponSchema = z
|
||||||
// .object({
|
// .object({
|
||||||
|
|
|
@ -20,7 +20,7 @@ import type {
|
||||||
PricingInterval,
|
PricingInterval,
|
||||||
PricingPlan,
|
PricingPlan,
|
||||||
PricingPlanLineItem,
|
PricingPlanLineItem,
|
||||||
PricingPlanMap
|
PricingPlanList
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
const usernameAndTeamSlugLength = 64 as const
|
const usernameAndTeamSlugLength = 64 as const
|
||||||
|
@ -164,15 +164,35 @@ export function getPricingPlanLineItemHashForStripePrice({
|
||||||
return `price:${pricingPlan.slug}:${pricingPlanLineItem.slug}:${hash}`
|
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({
|
export function getPricingPlansByInterval({
|
||||||
pricingInterval,
|
pricingInterval,
|
||||||
pricingPlanMap
|
pricingPlans
|
||||||
}: {
|
}: {
|
||||||
pricingInterval: PricingInterval
|
pricingInterval: PricingInterval
|
||||||
pricingPlanMap: PricingPlanMap
|
pricingPlans: PricingPlanList
|
||||||
}): PricingPlan[] {
|
}): PricingPlan[] {
|
||||||
return Object.values(pricingPlanMap).filter(
|
return pricingPlans.filter(
|
||||||
(pricingPlan) => pricingPlan.interval === pricingInterval
|
(pricingPlan) =>
|
||||||
|
pricingPlan.interval === undefined ||
|
||||||
|
pricingPlan.interval === pricingInterval
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -108,7 +108,7 @@ export async function upsertConsumer(
|
||||||
)
|
)
|
||||||
|
|
||||||
if (plan) {
|
if (plan) {
|
||||||
const pricingPlan = deployment.pricingPlanMap[plan]
|
const pricingPlan = deployment.pricingPlans.find((p) => p.slug === plan)
|
||||||
assert(
|
assert(
|
||||||
pricingPlan,
|
pricingPlan,
|
||||||
400,
|
400,
|
||||||
|
|
|
@ -242,13 +242,13 @@ export async function upsertStripePricing({
|
||||||
|
|
||||||
// Validate deployment pricing plans to ensure they contain at least one valid
|
// Validate deployment pricing plans to ensure they contain at least one valid
|
||||||
// plan per pricing interval configured on the project.
|
// 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
|
// We really wouldn't want to create some resources and then fail partway when
|
||||||
// this validation or some of the validation above fails.
|
// 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,
|
||||||
pricingPlanMap: deployment.pricingPlanMap
|
pricingPlans: deployment.pricingPlans
|
||||||
})
|
})
|
||||||
|
|
||||||
assert(
|
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) {
|
for (const pricingPlanLineItem of pricingPlan.lineItems) {
|
||||||
upserts.push(() =>
|
upserts.push(() =>
|
||||||
upsertStripeResourcesForPricingPlanLineItem({
|
upsertStripeResourcesForPricingPlanLineItem({
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {
|
||||||
type RawUser,
|
type RawUser,
|
||||||
schema
|
schema
|
||||||
} from '@/db'
|
} from '@/db'
|
||||||
|
import { getStripePriceIdForPricingPlanLineItem } from '@/db/schema'
|
||||||
import { stripe } from '@/lib/stripe'
|
import { stripe } from '@/lib/stripe'
|
||||||
import { assert } from '@/lib/utils'
|
import { assert } from '@/lib/utils'
|
||||||
|
|
||||||
|
@ -62,16 +63,43 @@ export async function upsertStripeSubscription(
|
||||||
|
|
||||||
if (consumer._stripeSubscriptionId) {
|
if (consumer._stripeSubscriptionId) {
|
||||||
// customer has an existing subscription
|
// customer has an existing subscription
|
||||||
const existing = await stripe.subscriptions.retrieve(
|
const existingStripeSubscription = await stripe.subscriptions.retrieve(
|
||||||
consumer._stripeSubscriptionId,
|
consumer._stripeSubscriptionId,
|
||||||
...stripeConnectParams
|
...stripeConnectParams
|
||||||
)
|
)
|
||||||
const existingItems = existing.items.data
|
const existingStripeSubscriptionItems =
|
||||||
|
existingStripeSubscription.items.data
|
||||||
logger.debug()
|
logger.debug()
|
||||||
logger.debug('existing subscription', JSON.stringify(existing, null, 2))
|
logger.debug(
|
||||||
|
'existing stripe subscription',
|
||||||
|
JSON.stringify(existingStripeSubscription, null, 2)
|
||||||
|
)
|
||||||
logger.debug()
|
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) {
|
if (plan) {
|
||||||
assert(
|
assert(
|
||||||
|
@ -80,62 +108,50 @@ export async function upsertStripeSubscription(
|
||||||
`Unable to update stripe subscription for invalid pricing plan "${plan}"`
|
`Unable to update stripe subscription for invalid pricing plan "${plan}"`
|
||||||
)
|
)
|
||||||
|
|
||||||
let items: Stripe.SubscriptionUpdateParams.Item[] = [
|
const items: Stripe.SubscriptionUpdateParams.Item[] =
|
||||||
{
|
pricingPlan.lineItems.map((lineItem) => {
|
||||||
plan: pricingPlan.stripeBasePlanId,
|
const priceId = getStripePriceIdForPricingPlanLineItem({
|
||||||
id: consumer.stripeSubscriptionBaseItemId
|
pricingPlan,
|
||||||
},
|
pricingPlanLineItem: lineItem,
|
||||||
{
|
project
|
||||||
plan: pricingPlan.stripeRequestPlanId,
|
})
|
||||||
id: consumer.stripeSubscriptionRequestItemId
|
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) {
|
// An existing Stripe Subscription Item may or may not exist for this
|
||||||
const { slug: metricSlug } = metric
|
// LineItem. It should exist if this is an update to an existing
|
||||||
logger.debug({
|
// LineItem. It won't exist if it's a new LineItem.
|
||||||
metricSlug,
|
const id = consumer._stripeSubscriptionItemIdMap[lineItem.slug]
|
||||||
plan: pricingPlan.stripeMetricPlans[metricSlug],
|
|
||||||
id: consumer.stripeSubscriptionMetricItems[metricSlug]
|
return {
|
||||||
|
price: priceId,
|
||||||
|
id,
|
||||||
|
metadata: {
|
||||||
|
lineItemSlug: lineItem.slug
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
items.push({
|
// Sanity check that LineItems we think should exist are all present in
|
||||||
plan: pricingPlan.stripeMetricPlans[metricSlug]!,
|
// the current subscription's items.
|
||||||
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)
|
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.id) {
|
if (item.id) {
|
||||||
const existingItem = existingItems.find(
|
const existingItem = existingStripeSubscriptionItems.find(
|
||||||
(existingItem) => item.id === existingItem.id
|
(existingItem) => item.id === existingItem.id
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!existingItem) {
|
assert(
|
||||||
logger.error(
|
existingItem,
|
||||||
'billing warning found new item that has a subscription item id but should not',
|
500,
|
||||||
{ item }
|
`Error updating stripe subscription: invalid pricing plan "${plan}" missing existing Subscription Item for "${item.id}"`
|
||||||
)
|
)
|
||||||
delete item.id
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: We should never use clear_usage because it causes us to lose money.
|
for (const existingItem of existingStripeSubscriptionItems) {
|
||||||
// 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) {
|
|
||||||
const updatedItem = items.find((item) => item.id === existingItem.id)
|
const updatedItem = items.find((item) => item.id === existingItem.id)
|
||||||
|
|
||||||
if (!updatedItem) {
|
if (!updatedItem) {
|
||||||
|
@ -144,10 +160,6 @@ export async function upsertStripeSubscription(
|
||||||
deleted: true
|
deleted: true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingItem.plan.usage_type === 'metered') {
|
|
||||||
deletedItem.clear_usage = true
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push(deletedItem)
|
items.push(deletedItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -164,63 +176,88 @@ export async function upsertStripeSubscription(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update.items = items
|
updateParams.items = items
|
||||||
|
|
||||||
if (pricingPlan.trialPeriodDays) {
|
if (pricingPlan.trialPeriodDays) {
|
||||||
update.trial_end =
|
const trialEnd =
|
||||||
Math.trunc(Date.now() / 1000) +
|
Math.trunc(Date.now() / 1000) +
|
||||||
24 * 60 * 60 * pricingPlan.trialPeriodDays
|
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 })
|
logger.debug('subscription', action, { items })
|
||||||
} else {
|
} else {
|
||||||
update.cancel_at_period_end = true
|
updateParams.cancel_at_period_end = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (project.isStripeConnectEnabled && project.applicationFeePercent > 0) {
|
if (project.isStripeConnectEnabled && project.applicationFeePercent > 0) {
|
||||||
update.application_fee_percent = project.applicationFeePercent
|
updateParams.application_fee_percent = project.applicationFeePercent
|
||||||
}
|
}
|
||||||
|
|
||||||
subscription = await stripe.subscriptions.update(
|
subscription = await stripe.subscriptions.update(
|
||||||
consumer._stripeSubscriptionId,
|
consumer._stripeSubscriptionId,
|
||||||
update,
|
updateParams,
|
||||||
...stripeConnectParams
|
...stripeConnectParams
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO: this will cancel the subscription without resolving current usage / invoices
|
// TODO: this will cancel the subscription without resolving current usage / invoices
|
||||||
// await stripe.subscriptions.del(consumer.stripeSubscription)
|
// await stripe.subscriptions.del(consumer.stripeSubscription)
|
||||||
} else {
|
} else {
|
||||||
|
// Creating a new subscription for this consumer for the first time.
|
||||||
assert(
|
assert(
|
||||||
pricingPlan,
|
pricingPlan,
|
||||||
404,
|
404,
|
||||||
`Unable to update stripe subscription for invalid pricing plan "${plan}"`
|
`Unable to update stripe subscription for invalid pricing plan "${plan}"`
|
||||||
)
|
)
|
||||||
|
|
||||||
let items: Stripe.SubscriptionCreateParams.Item[] = [
|
const items: Stripe.SubscriptionCreateParams.Item[] =
|
||||||
{
|
pricingPlan.lineItems.map((lineItem) => {
|
||||||
plan: pricingPlan.stripeBasePlanId
|
const priceId = getStripePriceIdForPricingPlanLineItem({
|
||||||
},
|
pricingPlan,
|
||||||
{
|
pricingPlanLineItem: lineItem,
|
||||||
plan: pricingPlan.stripeRequestPlanId
|
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) {
|
// An existing Stripe Subscription Item may or may not exist for this
|
||||||
const { slug: metricSlug } = metric
|
// LineItem. It should exist if this is an update to an existing
|
||||||
items.push({
|
// LineItem. It won't exist if it's a new LineItem.
|
||||||
plan: pricingPlan.stripeMetricPlans[metricSlug]!
|
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(
|
assert(
|
||||||
items.length,
|
items.length,
|
||||||
500,
|
500,
|
||||||
`Error creating stripe subscription for invalid plan "${pricingPlan.slug}"`
|
`Error creating stripe subscription: invalid plan "${plan}"`
|
||||||
)
|
)
|
||||||
|
|
||||||
const createParams: Stripe.SubscriptionCreateParams = {
|
const createParams: Stripe.SubscriptionCreateParams = {
|
||||||
customer: stripeCustomerId,
|
customer: stripeCustomerId,
|
||||||
|
description: `Agentic subscription to project "${project.id}"`,
|
||||||
// TODO: coupons
|
// TODO: coupons
|
||||||
// coupon: filterConsumerCoupon(ctx, consumer, deployment),
|
// coupon: filterConsumerCoupon(ctx, consumer, deployment),
|
||||||
items,
|
items,
|
||||||
|
@ -249,105 +286,54 @@ export async function upsertStripeSubscription(
|
||||||
consumer._stripeSubscriptionId = subscription.id
|
consumer._stripeSubscriptionId = subscription.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------
|
||||||
|
// Same codepath for updating, creating, and cancelling
|
||||||
|
// ----------------------------------------------------
|
||||||
|
|
||||||
assert(subscription, 500, 'Missing stripe subscription')
|
assert(subscription, 500, 'Missing stripe subscription')
|
||||||
logger.debug('subscription', subscription)
|
logger.debug('subscription', subscription)
|
||||||
|
|
||||||
const consumerUpdate: ConsumerUpdate = consumer
|
const consumerUpdate: ConsumerUpdate = consumer
|
||||||
|
consumerUpdate.stripeStatus = subscription.status
|
||||||
|
|
||||||
if (plan) {
|
// if (!plan) {
|
||||||
consumerUpdate.stripeStatus = subscription.status
|
// TODO: we cancel at the end of the billing interval, so we shouldn't
|
||||||
} else {
|
// invalidate the stripe subscription just yet. That should happen via
|
||||||
// TODO
|
// webhook. And we should never set `_stripeSubscriptionId` to `null`.
|
||||||
consumerUpdate._stripeSubscriptionId = null
|
// consumerUpdate._stripeSubscriptionId = null
|
||||||
consumerUpdate.stripeStatus = 'cancelled'
|
// consumerUpdate.stripeStatus = 'cancelled'
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (pricingPlan?.stripeBasePlanId) {
|
if (pricingPlan) {
|
||||||
const subscriptionItem = subscription.items.data.find(
|
for (const lineItem of pricingPlan.lineItems) {
|
||||||
(item) => item.plan.id === pricingPlan.stripeBasePlanId
|
const stripeSubscriptionItemId =
|
||||||
)
|
consumer._stripeSubscriptionItemIdMap[lineItem.slug]
|
||||||
assert(
|
|
||||||
subscriptionItem,
|
|
||||||
500,
|
|
||||||
`Error initializing stripe subscription for base plan "${subscription.id}"`
|
|
||||||
)
|
|
||||||
|
|
||||||
consumerUpdate.stripeSubscriptionBaseItemId = subscriptionItem.id
|
const stripeSubscriptionItem: Stripe.SubscriptionItem | undefined =
|
||||||
assert(
|
subscription.items.data.find((item) =>
|
||||||
consumerUpdate.stripeSubscriptionBaseItemId,
|
stripeSubscriptionItemId
|
||||||
500,
|
? item.id === stripeSubscriptionItemId
|
||||||
`Error initializing stripe subscription for base plan [${subscription.id}]`
|
: item.metadata?.lineItemSlug === lineItem.slug
|
||||||
)
|
|
||||||
} 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}]`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
consumerUpdate.stripeSubscriptionMetricItems![metricSlug] =
|
assert(
|
||||||
subscriptionItem.id
|
stripeSubscriptionItem,
|
||||||
assert(
|
500,
|
||||||
consumerUpdate.stripeSubscriptionMetricItems![metricSlug],
|
`Error post-processing stripe subscription for line item "${lineItem.slug}" on plan "${pricingPlan.slug}"`
|
||||||
500,
|
)
|
||||||
`Error initializing stripe subscription for metric "${metricSlug}" on plan [${subscription.id}]`
|
|
||||||
)
|
consumerUpdate._stripeSubscriptionItemIdMap![lineItem.slug] =
|
||||||
}
|
stripeSubscriptionItem.id
|
||||||
} else {
|
assert(
|
||||||
// TODO
|
consumerUpdate._stripeSubscriptionItemIdMap![lineItem.slug],
|
||||||
delete consumerUpdate.stripeSubscriptionMetricItems![metricSlug]
|
500,
|
||||||
|
`Error post-processing stripe subscription for line item "${lineItem.slug}" on plan "${pricingPlan.slug}"`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug()
|
logger.debug()
|
||||||
logger.debug('consumer update', {
|
logger.debug('consumer update', consumerUpdate)
|
||||||
...consumer,
|
|
||||||
...consumerUpdate
|
|
||||||
})
|
|
||||||
|
|
||||||
const [updatedConsumer] = await db
|
const [updatedConsumer] = await db
|
||||||
.update(schema.consumers)
|
.update(schema.consumers)
|
||||||
|
|
|
@ -11,7 +11,8 @@ export default [
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...drizzle.configs.recommended.rules,
|
...drizzle.configs.recommended.rules,
|
||||||
'no-console': 'error'
|
'no-console': 'error',
|
||||||
|
'unicorn/no-array-reduce': 'off'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
Ładowanie…
Reference in New Issue