feat: stripe subscription flow improvements

pull/715/head
Travis Fischer 2025-06-20 22:22:49 -05:00
rodzic e598187b08
commit 72750d1361
4 zmienionych plików z 311 dodań i 191 usunięć

Wyświetl plik

@ -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
} }

Wyświetl plik

@ -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
} }

Wyświetl plik

@ -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(

Wyświetl plik

@ -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(