kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
feat: stripe subscription flow improvements
rodzic
e598187b08
commit
72750d1361
|
@ -2,7 +2,16 @@ import type Stripe from 'stripe'
|
||||||
import { assert, HttpError } from '@agentic/platform-core'
|
import { assert, HttpError } from '@agentic/platform-core'
|
||||||
|
|
||||||
import type { HonoApp } from '@/lib/types'
|
import type { HonoApp } from '@/lib/types'
|
||||||
import { and, db, eq, type RawConsumer, schema } from '@/db'
|
import {
|
||||||
|
and,
|
||||||
|
db,
|
||||||
|
eq,
|
||||||
|
getStripePriceIdForPricingPlanLineItem,
|
||||||
|
type RawConsumer,
|
||||||
|
type RawDeployment,
|
||||||
|
type RawProject,
|
||||||
|
schema
|
||||||
|
} from '@/db'
|
||||||
import { setConsumerStripeSubscriptionStatus } from '@/lib/consumers/utils'
|
import { setConsumerStripeSubscriptionStatus } from '@/lib/consumers/utils'
|
||||||
import { env } from '@/lib/env'
|
import { env } from '@/lib/env'
|
||||||
import { stripe } from '@/lib/external/stripe'
|
import { stripe } from '@/lib/external/stripe'
|
||||||
|
@ -88,15 +97,22 @@ export function registerV1StripeWebhook(app: HonoApp) {
|
||||||
? subscriptionOrId
|
? subscriptionOrId
|
||||||
: subscriptionOrId.id
|
: subscriptionOrId.id
|
||||||
|
|
||||||
const [subscription, consumer] = await Promise.all([
|
const [subscription, consumer, deployment] = await Promise.all([
|
||||||
// Make sure we have the full subscription instead of just the id
|
// Make sure we have the full subscription instead of just the id
|
||||||
typeof subscriptionOrId === 'string'
|
typeof subscriptionOrId === 'string'
|
||||||
? stripe.subscriptions.retrieve(subscriptionId)
|
? stripe.subscriptions.retrieve(subscriptionId)
|
||||||
: subscriptionOrId,
|
: subscriptionOrId,
|
||||||
|
|
||||||
db.query.consumers.findFirst({
|
db.query.consumers.findFirst({
|
||||||
where: and(eq(schema.consumers.id, consumerId))
|
where: and(eq(schema.consumers.id, consumerId)),
|
||||||
})
|
with: { project: true }
|
||||||
|
}),
|
||||||
|
|
||||||
|
deploymentId
|
||||||
|
? db.query.deployments.findFirst({
|
||||||
|
where: and(eq(schema.deployments.id, deploymentId))
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
])
|
])
|
||||||
assert(
|
assert(
|
||||||
subscription,
|
subscription,
|
||||||
|
@ -104,6 +120,10 @@ export function registerV1StripeWebhook(app: HonoApp) {
|
||||||
`stripe subscription "${subscriptionId}" not found`
|
`stripe subscription "${subscriptionId}" not found`
|
||||||
)
|
)
|
||||||
assert(consumer, 404, `consumer "${consumerId}" not found`)
|
assert(consumer, 404, `consumer "${consumerId}" not found`)
|
||||||
|
if (deploymentId) {
|
||||||
|
assert(deployment, 404, `deployment "${deploymentId}" not found`)
|
||||||
|
}
|
||||||
|
const { project } = consumer
|
||||||
|
|
||||||
// TODO: Treat this as a transaction...
|
// TODO: Treat this as a transaction...
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
@ -119,6 +139,8 @@ export function registerV1StripeWebhook(app: HonoApp) {
|
||||||
// Sync our Consumer's state with the Stripe Subscription's state
|
// Sync our Consumer's state with the Stripe Subscription's state
|
||||||
syncConsumerWithStripeSubscription({
|
syncConsumerWithStripeSubscription({
|
||||||
consumer,
|
consumer,
|
||||||
|
deployment,
|
||||||
|
project,
|
||||||
subscription,
|
subscription,
|
||||||
plan,
|
plan,
|
||||||
userId,
|
userId,
|
||||||
|
@ -176,14 +198,29 @@ export function registerV1StripeWebhook(app: HonoApp) {
|
||||||
status: subscription.status
|
status: subscription.status
|
||||||
})
|
})
|
||||||
|
|
||||||
const consumer = await db.query.consumers.findFirst({
|
const [consumer, deployment] = await Promise.all([
|
||||||
where: eq(schema.consumers.id, consumerId)
|
db.query.consumers.findFirst({
|
||||||
})
|
where: eq(schema.consumers.id, consumerId),
|
||||||
|
with: { project: true }
|
||||||
|
}),
|
||||||
|
|
||||||
|
deploymentId
|
||||||
|
? db.query.deployments.findFirst({
|
||||||
|
where: and(eq(schema.deployments.id, deploymentId))
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
])
|
||||||
assert(consumer, 404, `consumer "${consumerId}" not found`)
|
assert(consumer, 404, `consumer "${consumerId}" not found`)
|
||||||
|
if (deploymentId) {
|
||||||
|
assert(deployment, 404, `deployment "${deploymentId}" not found`)
|
||||||
|
}
|
||||||
|
const { project } = consumer
|
||||||
|
|
||||||
// Sync our Consumer's state with the Stripe Subscription's state
|
// Sync our Consumer's state with the Stripe Subscription's state
|
||||||
await syncConsumerWithStripeSubscription({
|
await syncConsumerWithStripeSubscription({
|
||||||
consumer,
|
consumer,
|
||||||
|
deployment,
|
||||||
|
project,
|
||||||
subscription,
|
subscription,
|
||||||
plan,
|
plan,
|
||||||
userId,
|
userId,
|
||||||
|
@ -220,6 +257,8 @@ export function registerV1StripeWebhook(app: HonoApp) {
|
||||||
*/
|
*/
|
||||||
export async function syncConsumerWithStripeSubscription({
|
export async function syncConsumerWithStripeSubscription({
|
||||||
consumer,
|
consumer,
|
||||||
|
project,
|
||||||
|
deployment,
|
||||||
subscription,
|
subscription,
|
||||||
plan,
|
plan,
|
||||||
userId,
|
userId,
|
||||||
|
@ -227,12 +266,14 @@ export async function syncConsumerWithStripeSubscription({
|
||||||
deploymentId
|
deploymentId
|
||||||
}: {
|
}: {
|
||||||
consumer: RawConsumer
|
consumer: RawConsumer
|
||||||
|
project: RawProject
|
||||||
|
deployment?: RawDeployment
|
||||||
subscription: Stripe.Subscription
|
subscription: Stripe.Subscription
|
||||||
plan: string | null | undefined
|
plan: string | null | undefined
|
||||||
userId?: string
|
userId?: string
|
||||||
projectId?: string
|
projectId?: string
|
||||||
deploymentId?: string
|
deploymentId?: string
|
||||||
}) {
|
}): Promise<RawConsumer> {
|
||||||
// These extra checks aren't really necessary, but they're nice sanity checks
|
// These extra checks aren't really necessary, but they're nice sanity checks
|
||||||
// to ensure metadata consistency with our consumer
|
// to ensure metadata consistency with our consumer
|
||||||
assert(
|
assert(
|
||||||
|
@ -246,28 +287,65 @@ export async function syncConsumerWithStripeSubscription({
|
||||||
`consumer "${consumer.id}" project "${consumer.projectId}" does not match stripe checkout metadata project "${projectId}"`
|
`consumer "${consumer.id}" project "${consumer.projectId}" does not match stripe checkout metadata project "${projectId}"`
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
consumer._stripeSubscriptionId = subscription.id
|
||||||
consumer._stripeSubscriptionId !== subscription.id ||
|
consumer.stripeStatus = subscription.status
|
||||||
consumer.stripeStatus !== subscription.status ||
|
consumer.plan = plan as any // TODO: types
|
||||||
consumer.plan !== plan ||
|
setConsumerStripeSubscriptionStatus(consumer)
|
||||||
consumer.deploymentId !== deploymentId
|
|
||||||
) {
|
|
||||||
consumer._stripeSubscriptionId = subscription.id
|
|
||||||
consumer.stripeStatus = subscription.status
|
|
||||||
consumer.plan = plan as any // TODO: types
|
|
||||||
setConsumerStripeSubscriptionStatus(consumer)
|
|
||||||
|
|
||||||
if (deploymentId) {
|
if (deploymentId) {
|
||||||
consumer.deploymentId = deploymentId
|
consumer.deploymentId = deploymentId
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(schema.consumers)
|
|
||||||
.set(consumer)
|
|
||||||
.where(eq(schema.consumers.id, consumer.id))
|
|
||||||
|
|
||||||
// TODO: invoke provider webhooks
|
|
||||||
// event.data.customer = consumer.getPublicDocument()
|
|
||||||
// await invokeWebhooks(consumer.project, event)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pricingPlan = plan
|
||||||
|
? deployment?.pricingPlans.find((p) => p.slug === plan)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (pricingPlan) {
|
||||||
|
for (const lineItem of pricingPlan.lineItems) {
|
||||||
|
const stripeSubscriptionItemId =
|
||||||
|
consumer._stripeSubscriptionItemIdMap[lineItem.slug]
|
||||||
|
|
||||||
|
const stripePriceId: string | undefined = stripeSubscriptionItemId
|
||||||
|
? undefined
|
||||||
|
: await getStripePriceIdForPricingPlanLineItem({
|
||||||
|
pricingPlan,
|
||||||
|
pricingPlanLineItem: lineItem,
|
||||||
|
project
|
||||||
|
})
|
||||||
|
|
||||||
|
const stripeSubscriptionItem: Stripe.SubscriptionItem | undefined =
|
||||||
|
subscription.items.data.find((item) =>
|
||||||
|
stripeSubscriptionItemId
|
||||||
|
? item.id === stripeSubscriptionItemId
|
||||||
|
: item.price.id === stripePriceId
|
||||||
|
)
|
||||||
|
|
||||||
|
assert(
|
||||||
|
stripeSubscriptionItem,
|
||||||
|
500,
|
||||||
|
`Error post-processing stripe subscription "${subscription.id}" for line-item "${lineItem.slug}" on plan "${pricingPlan.slug}"`
|
||||||
|
)
|
||||||
|
|
||||||
|
consumer._stripeSubscriptionItemIdMap[lineItem.slug] =
|
||||||
|
stripeSubscriptionItem.id
|
||||||
|
assert(
|
||||||
|
consumer._stripeSubscriptionItemIdMap[lineItem.slug],
|
||||||
|
500,
|
||||||
|
`Error post-processing stripe subscription "${subscription.id}" for line-item "${lineItem.slug}" on plan "${pricingPlan.slug}"`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updatedConsumer] = await db
|
||||||
|
.update(schema.consumers)
|
||||||
|
.set(consumer)
|
||||||
|
.where(eq(schema.consumers.id, consumer.id))
|
||||||
|
.returning()
|
||||||
|
assert(updatedConsumer, 500, `consumer "${consumer.id}" not found`)
|
||||||
|
|
||||||
|
// TODO: invoke provider webhooks
|
||||||
|
// event.data.customer = consumer.getPublicDocument()
|
||||||
|
// await invokeWebhooks(consumer.project, event)
|
||||||
|
|
||||||
|
return updatedConsumer
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,7 +28,7 @@ export async function createStripeCheckoutSession(
|
||||||
project: RawProject
|
project: RawProject
|
||||||
plan?: string
|
plan?: string
|
||||||
}
|
}
|
||||||
): Promise<Stripe.Checkout.Session> {
|
): Promise<{ id: string; url: string }> {
|
||||||
const logger = ctx.get('logger')
|
const logger = ctx.get('logger')
|
||||||
const stripeConnectParams = project._stripeAccountId
|
const stripeConnectParams = project._stripeAccountId
|
||||||
? [
|
? [
|
||||||
|
@ -49,168 +49,201 @@ export async function createStripeCheckoutSession(
|
||||||
? deployment.pricingPlans.find((pricingPlan) => pricingPlan.slug === plan)
|
? deployment.pricingPlans.find((pricingPlan) => pricingPlan.slug === plan)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
// const action: 'create' | 'update' | 'cancel' = consumer._stripeSubscriptionId
|
const action: 'create' | 'update' | 'cancel' = consumer._stripeSubscriptionId
|
||||||
// ? plan
|
? plan
|
||||||
// ? 'update'
|
? 'update'
|
||||||
// : 'cancel'
|
: 'cancel'
|
||||||
// : 'create'
|
: 'create'
|
||||||
let checkoutSession: Stripe.Checkout.Session | undefined
|
|
||||||
|
// TODO: test cancel => resubscribe flow
|
||||||
|
|
||||||
if (consumer._stripeSubscriptionId) {
|
if (consumer._stripeSubscriptionId) {
|
||||||
// // customer has an existing subscription
|
// customer has an existing subscription
|
||||||
// const existingStripeSubscription = await stripe.subscriptions.retrieve(
|
const existingStripeSubscription = await stripe.subscriptions.retrieve(
|
||||||
// consumer._stripeSubscriptionId,
|
consumer._stripeSubscriptionId,
|
||||||
// ...stripeConnectParams
|
...stripeConnectParams
|
||||||
// )
|
)
|
||||||
// const existingStripeSubscriptionItems =
|
const existingStripeSubscriptionItems =
|
||||||
// existingStripeSubscription.items.data
|
existingStripeSubscription.items.data
|
||||||
// logger.debug()
|
logger.debug()
|
||||||
// logger.debug(
|
logger.debug(
|
||||||
// 'existing stripe subscription',
|
'existing stripe subscription',
|
||||||
// JSON.stringify(existingStripeSubscription, null, 2)
|
JSON.stringify(existingStripeSubscription, null, 2)
|
||||||
// )
|
)
|
||||||
// logger.debug()
|
logger.debug()
|
||||||
|
|
||||||
// assert(
|
assert(
|
||||||
// existingStripeSubscription.metadata?.userId === consumer.userId,
|
existingStripeSubscription.metadata?.userId === consumer.userId,
|
||||||
// 500,
|
500,
|
||||||
// `Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata.userId for consumer "${consumer.id}"`
|
`Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata.userId for consumer "${consumer.id}"`
|
||||||
// )
|
)
|
||||||
// assert(
|
assert(
|
||||||
// existingStripeSubscription.metadata?.consumerId === consumer.id,
|
existingStripeSubscription.metadata?.consumerId === consumer.id,
|
||||||
// 500,
|
500,
|
||||||
// `Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata.consumerId for consumer "${consumer.id}"`
|
`Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata.consumerId for consumer "${consumer.id}"`
|
||||||
// )
|
)
|
||||||
// assert(
|
assert(
|
||||||
// existingStripeSubscription.metadata?.projectId === project.id,
|
existingStripeSubscription.metadata?.projectId === project.id,
|
||||||
// 500,
|
500,
|
||||||
// `Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata.projectId for consumer "${consumer.id}"`
|
`Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata.projectId for consumer "${consumer.id}"`
|
||||||
// )
|
)
|
||||||
|
|
||||||
// const updateParams: Stripe.SubscriptionUpdateParams = {
|
if (!plan) {
|
||||||
// collection_method: 'charge_automatically',
|
const billingPortalSession = await stripe.billingPortal.sessions.create(
|
||||||
// metadata: {
|
{
|
||||||
// plan: plan ?? null,
|
customer: stripeCustomerId,
|
||||||
// consumerId: consumer.id,
|
return_url: `${env.AGENTIC_WEB_BASE_URL}/app/consumers`,
|
||||||
// userId: consumer.userId,
|
flow_data: {
|
||||||
// projectId: project.id,
|
type: 'subscription_cancel',
|
||||||
// deploymentId: deployment.id
|
subscription_cancel: {
|
||||||
// }
|
subscription: consumer._stripeSubscriptionId
|
||||||
// }
|
},
|
||||||
|
after_completion: {
|
||||||
|
type: 'redirect',
|
||||||
|
redirect: {
|
||||||
|
return_url: `${env.AGENTIC_WEB_BASE_URL}/app/consumers/${consumer.id}?checkout=canceled`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...stripeConnectParams
|
||||||
|
)
|
||||||
|
|
||||||
// if (plan) {
|
return {
|
||||||
// assert(
|
id: billingPortalSession.id,
|
||||||
// pricingPlan,
|
url: billingPortalSession.url
|
||||||
// 404,
|
}
|
||||||
// `Unable to update stripe subscription for invalid pricing plan "${plan}"`
|
}
|
||||||
// )
|
|
||||||
//
|
|
||||||
// const items: Stripe.SubscriptionUpdateParams.Item[] = await Promise.all(
|
|
||||||
// pricingPlan.lineItems.map(async (lineItem) => {
|
|
||||||
// const priceId = await getStripePriceIdForPricingPlanLineItem({
|
|
||||||
// pricingPlan,
|
|
||||||
// pricingPlanLineItem: lineItem,
|
|
||||||
// project
|
|
||||||
// })
|
|
||||||
// assert(
|
|
||||||
// priceId,
|
|
||||||
// 500,
|
|
||||||
// `Error updating stripe subscription: missing expected Stripe Price for plan "${pricingPlan.slug}" line-item "${lineItem.slug}"`
|
|
||||||
// )
|
|
||||||
|
|
||||||
// // An existing Stripe Subscription Item may or may not exist for this
|
const updateParams: Stripe.SubscriptionUpdateParams = {
|
||||||
// // LineItem. It should exist if this is an update to an existing
|
collection_method: 'charge_automatically',
|
||||||
// // LineItem. It won't exist if it's a new LineItem.
|
metadata: {
|
||||||
// const id = consumer._stripeSubscriptionItemIdMap[lineItem.slug]
|
plan: plan ?? null,
|
||||||
|
consumerId: consumer.id,
|
||||||
|
userId: consumer.userId,
|
||||||
|
projectId: project.id,
|
||||||
|
deploymentId: deployment.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// return {
|
assert(
|
||||||
// price: priceId,
|
pricingPlan,
|
||||||
// id,
|
404,
|
||||||
// metadata: {
|
`Unable to update stripe subscription for invalid pricing plan "${plan}"`
|
||||||
// lineItemSlug: lineItem.slug
|
)
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// )
|
|
||||||
|
|
||||||
// // Sanity check that LineItems we think should exist are all present in
|
const items: Stripe.SubscriptionUpdateParams.Item[] = await Promise.all(
|
||||||
// // the current subscription's items.
|
pricingPlan.lineItems.map(async (lineItem) => {
|
||||||
// for (const item of items) {
|
const priceId = await getStripePriceIdForPricingPlanLineItem({
|
||||||
// if (item.id) {
|
pricingPlan,
|
||||||
// const existingItem = existingStripeSubscriptionItems.find(
|
pricingPlanLineItem: lineItem,
|
||||||
// (existingItem) => item.id === existingItem.id
|
project
|
||||||
// )
|
})
|
||||||
|
assert(
|
||||||
|
priceId,
|
||||||
|
500,
|
||||||
|
`Error updating stripe subscription: missing expected Stripe Price for plan "${pricingPlan.slug}" line-item "${lineItem.slug}"`
|
||||||
|
)
|
||||||
|
|
||||||
// assert(
|
// An existing Stripe Subscription Item may or may not exist for this
|
||||||
// existingItem,
|
// LineItem. It should exist if this is an update to an existing
|
||||||
// 500,
|
// LineItem. It won't exist if it's a new LineItem.
|
||||||
// `Error updating stripe subscription: invalid pricing plan "${plan}" missing existing Subscription Item for "${item.id}"`
|
const id = consumer._stripeSubscriptionItemIdMap[lineItem.slug]
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// for (const existingItem of existingStripeSubscriptionItems) {
|
return {
|
||||||
// const updatedItem = items.find((item) => item.id === existingItem.id)
|
price: priceId,
|
||||||
|
id,
|
||||||
|
metadata: {
|
||||||
|
lineItemSlug: lineItem.slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
// if (!updatedItem) {
|
// Sanity check that LineItems we think should exist are all present in
|
||||||
// const deletedItem: Stripe.SubscriptionUpdateParams.Item = {
|
// the current subscription's items.
|
||||||
// id: existingItem.id,
|
for (const item of items) {
|
||||||
// deleted: true
|
if (item.id) {
|
||||||
// }
|
const existingItem = existingStripeSubscriptionItems.find(
|
||||||
|
(existingItem) => item.id === existingItem.id
|
||||||
|
)
|
||||||
|
|
||||||
// items.push(deletedItem)
|
assert(
|
||||||
// }
|
existingItem,
|
||||||
// }
|
500,
|
||||||
|
`Error updating stripe subscription: invalid pricing plan "${plan}" missing existing Subscription Item for "${item.id}"`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// assert(
|
for (const existingItem of existingStripeSubscriptionItems) {
|
||||||
// items.length || !plan,
|
const updatedItem = items.find((item) => item.id === existingItem.id)
|
||||||
// 500,
|
|
||||||
// `Error updating stripe subscription "${consumer._stripeSubscriptionId}"`
|
|
||||||
// )
|
|
||||||
|
|
||||||
// for (const item of items) {
|
if (!updatedItem) {
|
||||||
// if (!item.id) {
|
const deletedItem: Stripe.SubscriptionUpdateParams.Item = {
|
||||||
// delete item.id
|
id: existingItem.id,
|
||||||
// }
|
deleted: true
|
||||||
// }
|
}
|
||||||
|
|
||||||
// updateParams.items = items
|
items.push(deletedItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// if (pricingPlan.trialPeriodDays) {
|
assert(
|
||||||
// const trialEnd =
|
items.length || !plan,
|
||||||
// Math.trunc(Date.now() / 1000) +
|
500,
|
||||||
// 24 * 60 * 60 * pricingPlan.trialPeriodDays
|
`Error updating stripe subscription "${consumer._stripeSubscriptionId}"`
|
||||||
|
)
|
||||||
|
|
||||||
// // Reuse the existing trial end date if one exists. Otherwise, set a new
|
for (const item of items) {
|
||||||
// // one for the updated subscription.
|
if (!item.id) {
|
||||||
// updateParams.trial_end =
|
delete item.id
|
||||||
// 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 })
|
updateParams.items = items
|
||||||
// } else {
|
|
||||||
// updateParams.cancel_at_period_end = true
|
if (pricingPlan.trialPeriodDays) {
|
||||||
// }
|
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.info('>>> subscription', action, { items })
|
||||||
|
|
||||||
// TODO: Stripe Connect
|
// TODO: Stripe Connect
|
||||||
// if (project.isStripeConnectEnabled && project.applicationFeePercent > 0) {
|
// if (project.isStripeConnectEnabled && project.applicationFeePercent > 0) {
|
||||||
// updateParams.application_fee_percent = project.applicationFeePercent
|
// updateParams.application_fee_percent = project.applicationFeePercent
|
||||||
// }
|
// }
|
||||||
|
|
||||||
assert(false, 500, 'TODO: update subscription => createCheckoutSession')
|
const subscription = await stripe.subscriptions.update(
|
||||||
|
consumer._stripeSubscriptionId,
|
||||||
|
updateParams,
|
||||||
|
...stripeConnectParams
|
||||||
|
)
|
||||||
|
|
||||||
// subscription = await stripe.subscriptions.update(
|
logger.info('<<< subscription', action, subscription)
|
||||||
// consumer._stripeSubscriptionId,
|
|
||||||
// updateParams,
|
|
||||||
// ...stripeConnectParams
|
|
||||||
// )
|
|
||||||
|
|
||||||
// TODO: this will cancel the subscription without resolving current usage / invoices
|
const billingPortalSession = await stripe.billingPortal.sessions.create(
|
||||||
// await stripe.subscriptions.del(consumer.stripeSubscription)
|
{
|
||||||
|
customer: stripeCustomerId,
|
||||||
|
return_url: `${env.AGENTIC_WEB_BASE_URL}/app/consumers`
|
||||||
|
},
|
||||||
|
...stripeConnectParams
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: billingPortalSession.id,
|
||||||
|
url: billingPortalSession.url
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Creating a new subscription for this consumer for the first time.
|
// Creating a new subscription for this consumer for the first time.
|
||||||
assert(
|
assert(
|
||||||
|
@ -222,6 +255,16 @@ export async function createStripeCheckoutSession(
|
||||||
const items: Stripe.Checkout.SessionCreateParams.LineItem[] =
|
const items: Stripe.Checkout.SessionCreateParams.LineItem[] =
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
pricingPlan.lineItems.map(async (lineItem) => {
|
pricingPlan.lineItems.map(async (lineItem) => {
|
||||||
|
// 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}"`
|
||||||
|
)
|
||||||
|
|
||||||
const priceId = await getStripePriceIdForPricingPlanLineItem({
|
const priceId = await getStripePriceIdForPricingPlanLineItem({
|
||||||
pricingPlan,
|
pricingPlan,
|
||||||
pricingPlanLineItem: lineItem,
|
pricingPlanLineItem: lineItem,
|
||||||
|
@ -233,16 +276,6 @@ export async function createStripeCheckoutSession(
|
||||||
`Error creating stripe subscription: missing expected Stripe Price for plan "${pricingPlan.slug}" line item "${lineItem.slug}"`
|
`Error creating stripe subscription: missing expected Stripe Price for plan "${pricingPlan.slug}" line item "${lineItem.slug}"`
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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 {
|
return {
|
||||||
price: priceId,
|
price: priceId,
|
||||||
// TODO: Make this customizable
|
// TODO: Make this customizable
|
||||||
|
@ -267,11 +300,21 @@ export async function createStripeCheckoutSession(
|
||||||
success_url: `${env.AGENTIC_WEB_BASE_URL}/app/consumers/${consumer.id}?checkout=success&plan=${plan}`,
|
success_url: `${env.AGENTIC_WEB_BASE_URL}/app/consumers/${consumer.id}?checkout=success&plan=${plan}`,
|
||||||
cancel_url: `${env.AGENTIC_WEB_BASE_URL}/marketplace/projects/${project.identifier}?checkout=canceled`,
|
cancel_url: `${env.AGENTIC_WEB_BASE_URL}/marketplace/projects/${project.identifier}?checkout=canceled`,
|
||||||
submit_type: 'subscribe',
|
submit_type: 'subscribe',
|
||||||
|
saved_payment_method_options: {
|
||||||
|
payment_method_save: 'enabled'
|
||||||
|
},
|
||||||
subscription_data: {
|
subscription_data: {
|
||||||
description:
|
description:
|
||||||
pricingPlan.description ??
|
pricingPlan.description ??
|
||||||
`Subscription to ${project.name} ${pricingPlan.name}`,
|
`Subscription to ${project.name} ${pricingPlan.name}`,
|
||||||
trial_period_days: pricingPlan.trialPeriodDays
|
trial_period_days: pricingPlan.trialPeriodDays,
|
||||||
|
metadata: {
|
||||||
|
plan: plan ?? null,
|
||||||
|
consumerId: consumer.id,
|
||||||
|
userId: consumer.userId,
|
||||||
|
projectId: project.id,
|
||||||
|
deploymentId: deployment.id
|
||||||
|
}
|
||||||
// TODO: Stripe Connect
|
// TODO: Stripe Connect
|
||||||
// application_fee_percent: project.applicationFeePercent
|
// application_fee_percent: project.applicationFeePercent
|
||||||
},
|
},
|
||||||
|
@ -297,18 +340,15 @@ export async function createStripeCheckoutSession(
|
||||||
// }
|
// }
|
||||||
|
|
||||||
logger.debug('checkout session line_items', items)
|
logger.debug('checkout session line_items', items)
|
||||||
checkoutSession = await stripe.checkout.sessions.create(
|
const checkoutSession = await stripe.checkout.sessions.create(
|
||||||
checkoutSessionParams,
|
checkoutSessionParams,
|
||||||
...stripeConnectParams
|
...stripeConnectParams
|
||||||
)
|
)
|
||||||
|
assert(checkoutSession.url, 500, 'Missing stripe checkout session URL')
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: checkoutSession.id,
|
||||||
|
url: checkoutSession.url
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------
|
|
||||||
// Same codepath for updating, creating, and cancelling
|
|
||||||
// ----------------------------------------------------
|
|
||||||
|
|
||||||
assert(checkoutSession, 500, 'Missing stripe checkout session')
|
|
||||||
logger.debug('checkout session', checkoutSession)
|
|
||||||
|
|
||||||
return checkoutSession
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import type Stripe from 'stripe'
|
|
||||||
import { assert } from '@agentic/platform-core'
|
import { assert } from '@agentic/platform-core'
|
||||||
|
|
||||||
import type { AuthenticatedHonoContext } from '@/lib/types'
|
import type { AuthenticatedHonoContext } from '@/lib/types'
|
||||||
|
@ -32,7 +31,7 @@ export async function upsertConsumerStripeCheckout(
|
||||||
consumerId?: string
|
consumerId?: string
|
||||||
}
|
}
|
||||||
): Promise<{
|
): Promise<{
|
||||||
checkoutSession: Stripe.Checkout.Session
|
checkoutSession: { id: string; url: string }
|
||||||
consumer: RawConsumer
|
consumer: RawConsumer
|
||||||
}> {
|
}> {
|
||||||
assert(
|
assert(
|
||||||
|
|
|
@ -40,7 +40,10 @@ export function AppConsumerIndex({ consumerId }: { consumerId: string }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ctx || !consumer || !firstLoadConsumer.current) return
|
if (!ctx || !consumer || !firstLoadConsumer.current) return
|
||||||
|
|
||||||
if (checkout === 'success') {
|
if (checkout === 'canceled') {
|
||||||
|
firstLoadConsumer.current = false
|
||||||
|
toast('Subscription canceled')
|
||||||
|
} else if (checkout === 'success') {
|
||||||
if (plan) {
|
if (plan) {
|
||||||
firstLoadConsumer.current = false
|
firstLoadConsumer.current = false
|
||||||
toast(
|
toast(
|
||||||
|
|
Ładowanie…
Reference in New Issue