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 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 { env } from '@/lib/env'
import { stripe } from '@/lib/external/stripe'
@ -88,15 +97,22 @@ export function registerV1StripeWebhook(app: HonoApp) {
? subscriptionOrId
: 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
typeof subscriptionOrId === 'string'
? stripe.subscriptions.retrieve(subscriptionId)
: subscriptionOrId,
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(
subscription,
@ -104,6 +120,10 @@ export function registerV1StripeWebhook(app: HonoApp) {
`stripe subscription "${subscriptionId}" 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...
await Promise.all([
@ -119,6 +139,8 @@ export function registerV1StripeWebhook(app: HonoApp) {
// Sync our Consumer's state with the Stripe Subscription's state
syncConsumerWithStripeSubscription({
consumer,
deployment,
project,
subscription,
plan,
userId,
@ -176,14 +198,29 @@ export function registerV1StripeWebhook(app: HonoApp) {
status: subscription.status
})
const consumer = await db.query.consumers.findFirst({
where: eq(schema.consumers.id, consumerId)
})
const [consumer, deployment] = await Promise.all([
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`)
if (deploymentId) {
assert(deployment, 404, `deployment "${deploymentId}" not found`)
}
const { project } = consumer
// Sync our Consumer's state with the Stripe Subscription's state
await syncConsumerWithStripeSubscription({
consumer,
deployment,
project,
subscription,
plan,
userId,
@ -220,6 +257,8 @@ export function registerV1StripeWebhook(app: HonoApp) {
*/
export async function syncConsumerWithStripeSubscription({
consumer,
project,
deployment,
subscription,
plan,
userId,
@ -227,12 +266,14 @@ export async function syncConsumerWithStripeSubscription({
deploymentId
}: {
consumer: RawConsumer
project: RawProject
deployment?: RawDeployment
subscription: Stripe.Subscription
plan: string | null | undefined
userId?: string
projectId?: string
deploymentId?: string
}) {
}): Promise<RawConsumer> {
// These extra checks aren't really necessary, but they're nice sanity checks
// to ensure metadata consistency with our consumer
assert(
@ -246,28 +287,65 @@ export async function syncConsumerWithStripeSubscription({
`consumer "${consumer.id}" project "${consumer.projectId}" does not match stripe checkout metadata project "${projectId}"`
)
if (
consumer._stripeSubscriptionId !== subscription.id ||
consumer.stripeStatus !== subscription.status ||
consumer.plan !== plan ||
consumer.deploymentId !== deploymentId
) {
consumer._stripeSubscriptionId = subscription.id
consumer.stripeStatus = subscription.status
consumer.plan = plan as any // TODO: types
setConsumerStripeSubscriptionStatus(consumer)
consumer._stripeSubscriptionId = subscription.id
consumer.stripeStatus = subscription.status
consumer.plan = plan as any // TODO: types
setConsumerStripeSubscriptionStatus(consumer)
if (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)
if (deploymentId) {
consumer.deploymentId = deploymentId
}
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
plan?: string
}
): Promise<Stripe.Checkout.Session> {
): Promise<{ id: string; url: string }> {
const logger = ctx.get('logger')
const stripeConnectParams = project._stripeAccountId
? [
@ -49,168 +49,201 @@ export async function createStripeCheckoutSession(
? deployment.pricingPlans.find((pricingPlan) => pricingPlan.slug === plan)
: undefined
// const action: 'create' | 'update' | 'cancel' = consumer._stripeSubscriptionId
// ? plan
// ? 'update'
// : 'cancel'
// : 'create'
let checkoutSession: Stripe.Checkout.Session | undefined
const action: 'create' | 'update' | 'cancel' = consumer._stripeSubscriptionId
? plan
? 'update'
: 'cancel'
: 'create'
// TODO: test cancel => resubscribe flow
if (consumer._stripeSubscriptionId) {
// // customer has an existing subscription
// const existingStripeSubscription = await stripe.subscriptions.retrieve(
// consumer._stripeSubscriptionId,
// ...stripeConnectParams
// )
// const existingStripeSubscriptionItems =
// existingStripeSubscription.items.data
// logger.debug()
// logger.debug(
// 'existing stripe subscription',
// JSON.stringify(existingStripeSubscription, null, 2)
// )
// logger.debug()
// customer has an existing subscription
const existingStripeSubscription = await stripe.subscriptions.retrieve(
consumer._stripeSubscriptionId,
...stripeConnectParams
)
const existingStripeSubscriptionItems =
existingStripeSubscription.items.data
logger.debug()
logger.debug(
'existing stripe subscription',
JSON.stringify(existingStripeSubscription, null, 2)
)
logger.debug()
// assert(
// existingStripeSubscription.metadata?.userId === consumer.userId,
// 500,
// `Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata.userId for consumer "${consumer.id}"`
// )
// assert(
// existingStripeSubscription.metadata?.consumerId === consumer.id,
// 500,
// `Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata.consumerId for consumer "${consumer.id}"`
// )
// assert(
// existingStripeSubscription.metadata?.projectId === project.id,
// 500,
// `Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata.projectId for consumer "${consumer.id}"`
// )
assert(
existingStripeSubscription.metadata?.userId === consumer.userId,
500,
`Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata.userId for consumer "${consumer.id}"`
)
assert(
existingStripeSubscription.metadata?.consumerId === consumer.id,
500,
`Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata.consumerId for consumer "${consumer.id}"`
)
assert(
existingStripeSubscription.metadata?.projectId === project.id,
500,
`Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata.projectId for consumer "${consumer.id}"`
)
// const updateParams: Stripe.SubscriptionUpdateParams = {
// collection_method: 'charge_automatically',
// metadata: {
// plan: plan ?? null,
// consumerId: consumer.id,
// userId: consumer.userId,
// projectId: project.id,
// deploymentId: deployment.id
// }
// }
if (!plan) {
const billingPortalSession = await stripe.billingPortal.sessions.create(
{
customer: stripeCustomerId,
return_url: `${env.AGENTIC_WEB_BASE_URL}/app/consumers`,
flow_data: {
type: 'subscription_cancel',
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) {
// assert(
// pricingPlan,
// 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}"`
// )
return {
id: billingPortalSession.id,
url: billingPortalSession.url
}
}
// // 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]
const updateParams: Stripe.SubscriptionUpdateParams = {
collection_method: 'charge_automatically',
metadata: {
plan: plan ?? null,
consumerId: consumer.id,
userId: consumer.userId,
projectId: project.id,
deploymentId: deployment.id
}
}
// return {
// price: priceId,
// id,
// metadata: {
// lineItemSlug: lineItem.slug
// }
// }
// })
// )
assert(
pricingPlan,
404,
`Unable to update stripe subscription for invalid pricing plan "${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 = existingStripeSubscriptionItems.find(
// (existingItem) => item.id === existingItem.id
// )
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}"`
)
// assert(
// existingItem,
// 500,
// `Error updating stripe subscription: invalid pricing plan "${plan}" missing existing Subscription Item for "${item.id}"`
// )
// }
// }
// 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]
// for (const existingItem of existingStripeSubscriptionItems) {
// const updatedItem = items.find((item) => item.id === existingItem.id)
return {
price: priceId,
id,
metadata: {
lineItemSlug: lineItem.slug
}
}
})
)
// if (!updatedItem) {
// const deletedItem: Stripe.SubscriptionUpdateParams.Item = {
// id: existingItem.id,
// deleted: true
// }
// 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 = 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(
// items.length || !plan,
// 500,
// `Error updating stripe subscription "${consumer._stripeSubscriptionId}"`
// )
for (const existingItem of existingStripeSubscriptionItems) {
const updatedItem = items.find((item) => item.id === existingItem.id)
// for (const item of items) {
// if (!item.id) {
// delete item.id
// }
// }
if (!updatedItem) {
const deletedItem: Stripe.SubscriptionUpdateParams.Item = {
id: existingItem.id,
deleted: true
}
// updateParams.items = items
items.push(deletedItem)
}
}
// if (pricingPlan.trialPeriodDays) {
// const trialEnd =
// Math.trunc(Date.now() / 1000) +
// 24 * 60 * 60 * pricingPlan.trialPeriodDays
assert(
items.length || !plan,
500,
`Error updating stripe subscription "${consumer._stripeSubscriptionId}"`
)
// // 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'
// }
for (const item of items) {
if (!item.id) {
delete item.id
}
}
// logger.debug('subscription', action, { items })
// } else {
// updateParams.cancel_at_period_end = true
// }
updateParams.items = items
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
// if (project.isStripeConnectEnabled && project.applicationFeePercent > 0) {
// 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(
// consumer._stripeSubscriptionId,
// updateParams,
// ...stripeConnectParams
// )
logger.info('<<< subscription', action, subscription)
// TODO: this will cancel the subscription without resolving current usage / invoices
// await stripe.subscriptions.del(consumer.stripeSubscription)
const billingPortalSession = await stripe.billingPortal.sessions.create(
{
customer: stripeCustomerId,
return_url: `${env.AGENTIC_WEB_BASE_URL}/app/consumers`
},
...stripeConnectParams
)
return {
id: billingPortalSession.id,
url: billingPortalSession.url
}
} else {
// Creating a new subscription for this consumer for the first time.
assert(
@ -222,6 +255,16 @@ export async function createStripeCheckoutSession(
const items: Stripe.Checkout.SessionCreateParams.LineItem[] =
await Promise.all(
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({
pricingPlan,
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}"`
)
// 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,
// 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}`,
cancel_url: `${env.AGENTIC_WEB_BASE_URL}/marketplace/projects/${project.identifier}?checkout=canceled`,
submit_type: 'subscribe',
saved_payment_method_options: {
payment_method_save: 'enabled'
},
subscription_data: {
description:
pricingPlan.description ??
`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
// application_fee_percent: project.applicationFeePercent
},
@ -297,18 +340,15 @@ export async function createStripeCheckoutSession(
// }
logger.debug('checkout session line_items', items)
checkoutSession = await stripe.checkout.sessions.create(
const checkoutSession = await stripe.checkout.sessions.create(
checkoutSessionParams,
...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 type { AuthenticatedHonoContext } from '@/lib/types'
@ -32,7 +31,7 @@ export async function upsertConsumerStripeCheckout(
consumerId?: string
}
): Promise<{
checkoutSession: Stripe.Checkout.Session
checkoutSession: { id: string; url: string }
consumer: RawConsumer
}> {
assert(

Wyświetl plik

@ -40,7 +40,10 @@ export function AppConsumerIndex({ consumerId }: { consumerId: string }) {
useEffect(() => {
if (!ctx || !consumer || !firstLoadConsumer.current) return
if (checkout === 'success') {
if (checkout === 'canceled') {
firstLoadConsumer.current = false
toast('Subscription canceled')
} else if (checkout === 'success') {
if (plan) {
firstLoadConsumer.current = false
toast(