feat: add stripe billing portal session route

pull/715/head
Travis Fischer 2025-06-19 23:30:46 -05:00
rodzic 22e15c5e84
commit efe4b122d3
7 zmienionych plików z 143 dodań i 7 usunięć

Wyświetl plik

@ -0,0 +1,65 @@
import { assert } from '@agentic/platform-core'
import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
import type { AuthenticatedHonoEnv } from '@/lib/types'
import { db, eq, schema } from '@/db'
import { acl } from '@/lib/acl'
import { env } from '@/lib/env'
import { stripe } from '@/lib/external/stripe'
import {
openapiAuthenticatedSecuritySchemas,
openapiErrorResponse404,
openapiErrorResponse409,
openapiErrorResponse410,
openapiErrorResponses
} from '@/lib/openapi-utils'
import { consumerIdParamsSchema } from './schemas'
const route = createRoute({
description: 'Creates a Stripe billing portal session for a customer.',
tags: ['consumers'],
operationId: 'createConsumerBillingPortalSession',
method: 'post',
path: 'consumers/{consumerId}/billing-portal',
security: openapiAuthenticatedSecuritySchemas,
request: {
params: consumerIdParamsSchema
},
responses: {
200: {
description: 'A billing portal session URL',
content: {
'application/json': {
schema: z.object({
url: z.string().url()
})
}
}
},
...openapiErrorResponses,
...openapiErrorResponse404,
...openapiErrorResponse409,
...openapiErrorResponse410
}
})
export function registerV1CreateConsumerBillingPortalSession(
app: OpenAPIHono<AuthenticatedHonoEnv>
) {
return app.openapi(route, async (c) => {
const { consumerId } = c.req.valid('param')
const consumer = await db.query.consumers.findFirst({
where: eq(schema.consumers.id, consumerId)
})
assert(consumer, 404, `Consumer not found "${consumerId}"`)
await acl(c, consumer, { label: 'Consumer' })
const portalSession = await stripe.billingPortal.sessions.create({
customer: consumer._stripeCustomerId,
return_url: `${env.AGENTIC_WEB_BASE_URL}/app/consumers/${consumerId}`
})
return c.json({ url: portalSession.url })
})
}

Wyświetl plik

@ -14,6 +14,7 @@ import { registerV1SignUpWithPassword } from './auth/sign-up-with-password'
import { registerV1AdminActivateConsumer } from './consumers/admin-activate-consumer' import { registerV1AdminActivateConsumer } from './consumers/admin-activate-consumer'
import { registerV1AdminGetConsumerByToken } from './consumers/admin-get-consumer-by-token' import { registerV1AdminGetConsumerByToken } from './consumers/admin-get-consumer-by-token'
import { registerV1CreateConsumer } from './consumers/create-consumer' import { registerV1CreateConsumer } from './consumers/create-consumer'
import { registerV1CreateConsumerBillingPortalSession } from './consumers/create-consumer-billing-portal-session'
import { registerV1CreateConsumerCheckoutSession } from './consumers/create-consumer-checkout-session' import { registerV1CreateConsumerCheckoutSession } from './consumers/create-consumer-checkout-session'
import { registerV1GetConsumer } from './consumers/get-consumer' import { registerV1GetConsumer } from './consumers/get-consumer'
import { registerV1GetConsumerByProjectIdentifier } from './consumers/get-consumer-by-project-identifier' import { registerV1GetConsumerByProjectIdentifier } from './consumers/get-consumer-by-project-identifier'
@ -119,10 +120,11 @@ registerV1GetProject(privateRouter)
registerV1UpdateProject(privateRouter) registerV1UpdateProject(privateRouter)
// Consumers // Consumers
registerV1GetConsumerByProjectIdentifier(privateRouter) // msut be before `registerV1GetConsumer` registerV1GetConsumerByProjectIdentifier(privateRouter) // must be before `registerV1GetConsumer`
registerV1GetConsumer(privateRouter) registerV1GetConsumer(privateRouter)
registerV1CreateConsumer(privateRouter) registerV1CreateConsumer(privateRouter)
registerV1CreateConsumerCheckoutSession(privateRouter) registerV1CreateConsumerCheckoutSession(privateRouter)
registerV1CreateConsumerBillingPortalSession(privateRouter)
registerV1UpdateConsumer(privateRouter) registerV1UpdateConsumer(privateRouter)
registerV1RefreshConsumerToken(privateRouter) registerV1RefreshConsumerToken(privateRouter)
registerV1ListConsumers(privateRouter) registerV1ListConsumers(privateRouter)

Wyświetl plik

@ -254,6 +254,7 @@ export async function syncConsumerWithStripeSubscription({
) { ) {
consumer._stripeSubscriptionId = subscription.id consumer._stripeSubscriptionId = subscription.id
consumer.stripeStatus = subscription.status consumer.stripeStatus = subscription.status
consumer.plan = plan as any // TODO: types
setConsumerStripeSubscriptionStatus(consumer) setConsumerStripeSubscriptionStatus(consumer)
if (deploymentId) { if (deploymentId) {

Wyświetl plik

@ -196,11 +196,15 @@ export async function upsertConsumerStripeCheckout(
// TODO: this function may mutate `consumer` // TODO: this function may mutate `consumer`
await upsertStripeConnectCustomer({ stripeCustomer, consumer, project }) await upsertStripeConnectCustomer({ stripeCustomer, consumer, project })
logger.info('SUBSCRIPTION', existingConsumer ? 'UPDATE' : 'CREATE', { logger.info(
project, 'CONSUMER STRIPE CHECKOUT',
deployment, existingConsumer ? 'UPDATE' : 'CREATE',
consumer {
}) project,
deployment,
consumer
}
)
const checkoutSession = await createStripeCheckoutSession(c, { const checkoutSession = await createStripeCheckoutSession(c, {
consumer, consumer,

Wyświetl plik

@ -561,6 +561,22 @@ export class AgenticApiClient {
.json() .json()
} }
/**
* Creates a Stripe Billing Portal Session for a customer.
*/
async createConsumerBillingPortalSession({
consumerId,
...searchParams
}: OperationParameters<'createConsumerBillingPortalSession'>): Promise<{
url: string
}> {
return this.ky
.post(`v1/consumers/${consumerId}/billing-portal`, {
searchParams: sanitizeSearchParams(searchParams)
})
.json()
}
/** Refreshes a consumer's API token. */ /** Refreshes a consumer's API token. */
async refreshConsumerToken({ async refreshConsumerToken({
consumerId, consumerId,

Wyświetl plik

@ -353,6 +353,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/v1/consumers/{consumerId}/billing-portal": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** @description Creates a Stripe billing portal session for a customer. */
post: operations["createConsumerBillingPortalSession"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/consumers/{consumerId}/refresh-token": { "/v1/consumers/{consumerId}/refresh-token": {
parameters: { parameters: {
query?: never; query?: never;
@ -1935,6 +1952,38 @@ export interface operations {
410: components["responses"]["410"]; 410: components["responses"]["410"];
}; };
}; };
createConsumerBillingPortalSession: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Consumer ID */
consumerId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description A billing portal session URL */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** Format: uri */
url: string;
};
};
};
400: components["responses"]["400"];
401: components["responses"]["401"];
403: components["responses"]["403"];
404: components["responses"]["404"];
409: components["responses"]["409"];
410: components["responses"]["410"];
};
};
refreshConsumerToken: { refreshConsumerToken: {
parameters: { parameters: {
query?: never; query?: never;

Wyświetl plik

@ -44,7 +44,6 @@
- consider using [neon serverless driver](https://orm.drizzle.team/docs/connect-neon) for production - consider using [neon serverless driver](https://orm.drizzle.team/docs/connect-neon) for production
- can this also be used locally? - can this also be used locally?
- may need to update our `drizzle-orm` fork - may need to update our `drizzle-orm` fork
- figure out the best OSS license for launch
## TODO: Post-MVP ## TODO: Post-MVP