From efe4b122d3370879b3beee86cac99fc3050fdcb2 Mon Sep 17 00:00:00 2001 From: Travis Fischer Date: Thu, 19 Jun 2025 23:30:46 -0500 Subject: [PATCH] feat: add stripe billing portal session route --- .../create-consumer-billing-portal-session.ts | 65 +++++++++++++++++++ apps/api/src/api-v1/index.ts | 4 +- .../api/src/api-v1/webhooks/stripe-webhook.ts | 1 + .../upsert-consumer-stripe-checkout.ts | 14 ++-- packages/api-client/src/agentic-api-client.ts | 16 +++++ packages/types/src/openapi.d.ts | 49 ++++++++++++++ readme.md | 1 - 7 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/api-v1/consumers/create-consumer-billing-portal-session.ts diff --git a/apps/api/src/api-v1/consumers/create-consumer-billing-portal-session.ts b/apps/api/src/api-v1/consumers/create-consumer-billing-portal-session.ts new file mode 100644 index 00000000..02f2e30d --- /dev/null +++ b/apps/api/src/api-v1/consumers/create-consumer-billing-portal-session.ts @@ -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 +) { + 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 }) + }) +} diff --git a/apps/api/src/api-v1/index.ts b/apps/api/src/api-v1/index.ts index 2be4f927..2cfd67ee 100644 --- a/apps/api/src/api-v1/index.ts +++ b/apps/api/src/api-v1/index.ts @@ -14,6 +14,7 @@ import { registerV1SignUpWithPassword } from './auth/sign-up-with-password' import { registerV1AdminActivateConsumer } from './consumers/admin-activate-consumer' import { registerV1AdminGetConsumerByToken } from './consumers/admin-get-consumer-by-token' import { registerV1CreateConsumer } from './consumers/create-consumer' +import { registerV1CreateConsumerBillingPortalSession } from './consumers/create-consumer-billing-portal-session' import { registerV1CreateConsumerCheckoutSession } from './consumers/create-consumer-checkout-session' import { registerV1GetConsumer } from './consumers/get-consumer' import { registerV1GetConsumerByProjectIdentifier } from './consumers/get-consumer-by-project-identifier' @@ -119,10 +120,11 @@ registerV1GetProject(privateRouter) registerV1UpdateProject(privateRouter) // Consumers -registerV1GetConsumerByProjectIdentifier(privateRouter) // msut be before `registerV1GetConsumer` +registerV1GetConsumerByProjectIdentifier(privateRouter) // must be before `registerV1GetConsumer` registerV1GetConsumer(privateRouter) registerV1CreateConsumer(privateRouter) registerV1CreateConsumerCheckoutSession(privateRouter) +registerV1CreateConsumerBillingPortalSession(privateRouter) registerV1UpdateConsumer(privateRouter) registerV1RefreshConsumerToken(privateRouter) registerV1ListConsumers(privateRouter) diff --git a/apps/api/src/api-v1/webhooks/stripe-webhook.ts b/apps/api/src/api-v1/webhooks/stripe-webhook.ts index f6449853..1f0ab7d7 100644 --- a/apps/api/src/api-v1/webhooks/stripe-webhook.ts +++ b/apps/api/src/api-v1/webhooks/stripe-webhook.ts @@ -254,6 +254,7 @@ export async function syncConsumerWithStripeSubscription({ ) { consumer._stripeSubscriptionId = subscription.id consumer.stripeStatus = subscription.status + consumer.plan = plan as any // TODO: types setConsumerStripeSubscriptionStatus(consumer) if (deploymentId) { diff --git a/apps/api/src/lib/consumers/upsert-consumer-stripe-checkout.ts b/apps/api/src/lib/consumers/upsert-consumer-stripe-checkout.ts index 843d0946..8f8ea05b 100644 --- a/apps/api/src/lib/consumers/upsert-consumer-stripe-checkout.ts +++ b/apps/api/src/lib/consumers/upsert-consumer-stripe-checkout.ts @@ -196,11 +196,15 @@ export async function upsertConsumerStripeCheckout( // TODO: this function may mutate `consumer` await upsertStripeConnectCustomer({ stripeCustomer, consumer, project }) - logger.info('SUBSCRIPTION', existingConsumer ? 'UPDATE' : 'CREATE', { - project, - deployment, - consumer - }) + logger.info( + 'CONSUMER STRIPE CHECKOUT', + existingConsumer ? 'UPDATE' : 'CREATE', + { + project, + deployment, + consumer + } + ) const checkoutSession = await createStripeCheckoutSession(c, { consumer, diff --git a/packages/api-client/src/agentic-api-client.ts b/packages/api-client/src/agentic-api-client.ts index 6c747ee4..3e90d306 100644 --- a/packages/api-client/src/agentic-api-client.ts +++ b/packages/api-client/src/agentic-api-client.ts @@ -561,6 +561,22 @@ export class AgenticApiClient { .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. */ async refreshConsumerToken({ consumerId, diff --git a/packages/types/src/openapi.d.ts b/packages/types/src/openapi.d.ts index 10063175..7618e06b 100644 --- a/packages/types/src/openapi.d.ts +++ b/packages/types/src/openapi.d.ts @@ -353,6 +353,23 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -1935,6 +1952,38 @@ export interface operations { 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: { parameters: { query?: never; diff --git a/readme.md b/readme.md index 39b7d3db..34dc6ae9 100644 --- a/readme.md +++ b/readme.md @@ -44,7 +44,6 @@ - consider using [neon serverless driver](https://orm.drizzle.team/docs/connect-neon) for production - can this also be used locally? - may need to update our `drizzle-orm` fork -- figure out the best OSS license for launch ## TODO: Post-MVP