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