From 2484e7efdb30290a7074bf9672da0d961f7a8ac7 Mon Sep 17 00:00:00 2001 From: Travis Fischer Date: Tue, 20 May 2025 14:47:03 +0700 Subject: [PATCH] feat: replace enabled with deletedAt for soft deletes --- .../src/api-v1/consumers/create-consumer.ts | 2 +- .../src/api-v1/consumers/update-consumer.ts | 2 +- .../api/src/api-v1/webhooks/stripe-webhook.ts | 14 +++-------- apps/api/src/db/index.ts | 3 +-- apps/api/src/db/schema.ts | 2 +- .../lib/billing/upsert-stripe-subscription.ts | 7 ++++-- .../{billing => consumers}/upsert-consumer.ts | 15 ++++++----- apps/api/src/lib/consumers/utils.ts | 20 +++++++++++++++ packages/db/src/schema/common.ts | 3 ++- packages/db/src/schema/consumer.ts | 13 +++++++--- packages/db/src/schema/deployment.ts | 7 +++--- packages/db/src/schema/log-entry.ts | 3 ++- packages/db/src/schema/project.ts | 3 ++- packages/db/src/schema/team-member.ts | 3 ++- packages/db/src/schema/team.ts | 3 ++- packages/db/src/schema/user.ts | 3 ++- packages/db/src/types.test.ts | 25 ++++++++++++++++++- packages/db/src/types.ts | 2 +- readme.md | 3 +-- 19 files changed, 89 insertions(+), 44 deletions(-) rename apps/api/src/lib/{billing => consumers}/upsert-consumer.ts (89%) create mode 100644 apps/api/src/lib/consumers/utils.ts diff --git a/apps/api/src/api-v1/consumers/create-consumer.ts b/apps/api/src/api-v1/consumers/create-consumer.ts index 10d1a146..7794e9d5 100644 --- a/apps/api/src/api-v1/consumers/create-consumer.ts +++ b/apps/api/src/api-v1/consumers/create-consumer.ts @@ -3,7 +3,7 @@ import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedEnv } from '@/lib/types' import { schema } from '@/db' -import { upsertConsumer } from '@/lib/billing/upsert-consumer' +import { upsertConsumer } from '@/lib/consumers/upsert-consumer' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, diff --git a/apps/api/src/api-v1/consumers/update-consumer.ts b/apps/api/src/api-v1/consumers/update-consumer.ts index 44ed652f..8be6d6df 100644 --- a/apps/api/src/api-v1/consumers/update-consumer.ts +++ b/apps/api/src/api-v1/consumers/update-consumer.ts @@ -3,7 +3,7 @@ import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedEnv } from '@/lib/types' import { schema } from '@/db' -import { upsertConsumer } from '@/lib/billing/upsert-consumer' +import { upsertConsumer } from '@/lib/consumers/upsert-consumer' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, diff --git a/apps/api/src/api-v1/webhooks/stripe-webhook.ts b/apps/api/src/api-v1/webhooks/stripe-webhook.ts index 286d97ea..2ad43c86 100644 --- a/apps/api/src/api-v1/webhooks/stripe-webhook.ts +++ b/apps/api/src/api-v1/webhooks/stripe-webhook.ts @@ -3,6 +3,7 @@ import type Stripe from 'stripe' import { assert, HttpError } from '@agentic/platform-core' import { and, db, eq, schema } from '@/db' +import { setConsumerStripeSubscriptionStatus } from '@/lib/consumers/utils' import { env, isStripeLive } from '@/lib/env' import { stripe } from '@/lib/stripe' @@ -10,13 +11,6 @@ const relevantStripeEvents = new Set([ 'customer.subscription.updated' ]) -const stripeValidSubscriptionStatuses = new Set([ - 'active', - 'trialing', - 'incomplete', - 'past_due' -]) - export function registerV1StripeWebhook(app: OpenAPIHono) { return app.post('/webhooks/stripe', async (ctx) => { const body = await ctx.req.text() @@ -81,15 +75,13 @@ export function registerV1StripeWebhook(app: OpenAPIHono) { if (consumer.stripeStatus !== subscription.status) { consumer.stripeStatus = subscription.status - consumer.enabled = - consumer.plan === 'free' || - stripeValidSubscriptionStatuses.has(consumer.stripeStatus) + setConsumerStripeSubscriptionStatus(consumer) await db .update(schema.consumers) .set({ stripeStatus: consumer.stripeStatus, - enabled: consumer.enabled + isStripeSubscriptionActive: consumer.isStripeSubscriptionActive }) .where(eq(schema.consumers.id, consumer.id)) diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts index 60ae2b18..1dc3ff19 100644 --- a/apps/api/src/db/index.ts +++ b/apps/api/src/db/index.ts @@ -1,6 +1,6 @@ + // The only place we allow `@agentic/platform-db` imports is in this directory. - import { drizzle, postgres, @@ -16,5 +16,4 @@ const postgresClient = export const db = drizzle({ client: postgresClient, schema }) - export * from '@agentic/platform-db' diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 76b4c213..2063d66c 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1,4 +1,4 @@ + // The only place we allow `@agentic/platform-db` imports is in this directory. - export * from '@agentic/platform-db' diff --git a/apps/api/src/lib/billing/upsert-stripe-subscription.ts b/apps/api/src/lib/billing/upsert-stripe-subscription.ts index bccdbe71..79355c77 100644 --- a/apps/api/src/lib/billing/upsert-stripe-subscription.ts +++ b/apps/api/src/lib/billing/upsert-stripe-subscription.ts @@ -3,11 +3,11 @@ import { assert } from '@agentic/platform-core' import type { AuthenticatedContext } from '@/lib/types' import { - type ConsumerUpdate, db, eq, getStripePriceIdForPricingPlanLineItem, type RawConsumer, + type RawConsumerUpdate, type RawDeployment, type RawProject, type RawUser, @@ -15,6 +15,8 @@ import { } from '@/db' import { stripe } from '@/lib/stripe' +import { setConsumerStripeSubscriptionStatus } from '../consumers/utils' + export async function upsertStripeSubscription( ctx: AuthenticatedContext, { @@ -292,8 +294,9 @@ export async function upsertStripeSubscription( assert(subscription, 500, 'Missing stripe subscription') logger.debug('subscription', subscription) - const consumerUpdate: ConsumerUpdate = consumer + const consumerUpdate: RawConsumerUpdate = consumer consumerUpdate.stripeStatus = subscription.status + setConsumerStripeSubscriptionStatus(consumerUpdate) // if (!plan) { // TODO: we cancel at the end of the billing interval, so we shouldn't diff --git a/apps/api/src/lib/billing/upsert-consumer.ts b/apps/api/src/lib/consumers/upsert-consumer.ts similarity index 89% rename from apps/api/src/lib/billing/upsert-consumer.ts rename to apps/api/src/lib/consumers/upsert-consumer.ts index 97f5dbb3..cf2787d9 100644 --- a/apps/api/src/lib/billing/upsert-consumer.ts +++ b/apps/api/src/lib/consumers/upsert-consumer.ts @@ -4,13 +4,12 @@ import { parseFaasIdentifier } from '@agentic/platform-validators' import type { AuthenticatedContext } from '@/lib/types' import { and, db, eq, schema } from '@/db' import { acl } from '@/lib/acl' +import { upsertStripeConnectCustomer } from '@/lib/billing/upsert-stripe-connect-customer' +import { upsertStripeCustomer } from '@/lib/billing/upsert-stripe-customer' +import { upsertStripePricing } from '@/lib/billing/upsert-stripe-pricing' +import { upsertStripeSubscription } from '@/lib/billing/upsert-stripe-subscription' import { createConsumerToken } from '@/lib/create-consumer-token' -import { upsertStripeConnectCustomer } from './upsert-stripe-connect-customer' -import { upsertStripeCustomer } from './upsert-stripe-customer' -import { upsertStripePricing } from './upsert-stripe-pricing' -import { upsertStripeSubscription } from './upsert-stripe-subscription' - export async function upsertConsumer( c: AuthenticatedContext, { @@ -80,7 +79,7 @@ export async function upsertConsumer( assert( !existingConsumer || - !existingConsumer.enabled || + !existingConsumer.isStripeSubscriptionActive || existingConsumer.plan !== plan || existingConsumer.deploymentId !== deploymentId, 409, @@ -102,9 +101,9 @@ export async function upsertConsumer( `Project not found "${projectId}" for deployment "${deploymentId}"` ) assert( - deployment.enabled, + !deployment.deletedAt, 410, - `Deployment has been disabled by its owner "${deployment.id}"` + `Deployment has been deleted by its owner "${deployment.id}"` ) if (plan) { diff --git a/apps/api/src/lib/consumers/utils.ts b/apps/api/src/lib/consumers/utils.ts new file mode 100644 index 00000000..7548199c --- /dev/null +++ b/apps/api/src/lib/consumers/utils.ts @@ -0,0 +1,20 @@ +import type { RawConsumerUpdate } from '@/db' + +const stripeValidSubscriptionStatuses = new Set([ + 'active', + 'trialing', + 'incomplete', + 'past_due' +]) + +export function setConsumerStripeSubscriptionStatus( + consumer: Pick< + RawConsumerUpdate, + 'plan' | 'stripeStatus' | 'isStripeSubscriptionActive' + > +) { + consumer.isStripeSubscriptionActive = + consumer.plan === 'free' || + (!!consumer.stripeStatus && + stripeValidSubscriptionStatuses.has(consumer.stripeStatus)) +} diff --git a/packages/db/src/schema/common.ts b/packages/db/src/schema/common.ts index e0da409a..59f299de 100644 --- a/packages/db/src/schema/common.ts +++ b/packages/db/src/schema/common.ts @@ -87,7 +87,8 @@ export const timestamps = { createdAt: timestamp().notNull().defaultNow(), updatedAt: timestamp() .notNull() - .default(sql`now()`) + .default(sql`now()`), + deletedAt: timestamp() } export const userRoleEnum = pgEnum('UserRole', ['user', 'admin']) diff --git a/packages/db/src/schema/consumer.ts b/packages/db/src/schema/consumer.ts index 42469a6c..efb9855a 100644 --- a/packages/db/src/schema/consumer.ts +++ b/packages/db/src/schema/consumer.ts @@ -59,9 +59,6 @@ export const consumers = pgTable( // initializing their subscription. activated: boolean().default(false).notNull(), - // Whether the consumer's subscription is currently active - enabled: boolean().default(true).notNull(), - // TODO: Re-add coupon support // coupon: text(), @@ -90,6 +87,10 @@ export const consumers = pgTable( // Stripe subscription status (synced via webhooks) stripeStatus: text(), + // Whether the consumer's subscription is currently active, depending on + // `stripeStatus`. + isStripeSubscriptionActive: boolean().default(true).notNull(), + // Main Stripe Subscription id _stripeSubscriptionId: stripeId(), @@ -108,8 +109,12 @@ export const consumers = pgTable( index('consumer_userId_idx').on(table.userId), index('consumer_projectId_idx').on(table.projectId), index('consumer_deploymentId_idx').on(table.deploymentId), + index('consumer_isStripeSubscriptionActive_idx').on( + table.isStripeSubscriptionActive + ), index('consumer_createdAt_idx').on(table.createdAt), - index('consumer_updatedAt_idx').on(table.updatedAt) + index('consumer_updatedAt_idx').on(table.updatedAt), + index('consumer_deletedAt_idx').on(table.deletedAt) ] ) diff --git a/packages/db/src/schema/deployment.ts b/packages/db/src/schema/deployment.ts index 2b722a42..e0ee6e2f 100644 --- a/packages/db/src/schema/deployment.ts +++ b/packages/db/src/schema/deployment.ts @@ -38,7 +38,6 @@ export const deployments = pgTable( hash: text().notNull(), version: text(), - enabled: boolean().default(true).notNull(), published: boolean().default(false).notNull(), description: text().default('').notNull(), @@ -82,11 +81,11 @@ export const deployments = pgTable( index('deployment_userId_idx').on(table.userId), index('deployment_teamId_idx').on(table.teamId), index('deployment_projectId_idx').on(table.projectId), - index('deployment_enabled_idx').on(table.enabled), index('deployment_published_idx').on(table.published), index('deployment_version_idx').on(table.version), index('deployment_createdAt_idx').on(table.createdAt), - index('deployment_updatedAt_idx').on(table.updatedAt) + index('deployment_updatedAt_idx').on(table.updatedAt), + index('deployment_deletedAt_idx').on(table.deletedAt) ] ) @@ -190,7 +189,7 @@ NOTE: Agentic currently only supports \`external\` API servers. If you'd like to export const deploymentUpdateSchema = createUpdateSchema(deployments) .pick({ - enabled: true, + deletedAt: true, description: true }) .strict() diff --git a/packages/db/src/schema/log-entry.ts b/packages/db/src/schema/log-entry.ts index 6fa0b3f0..4aa724f9 100644 --- a/packages/db/src/schema/log-entry.ts +++ b/packages/db/src/schema/log-entry.ts @@ -58,7 +58,8 @@ export const logEntries = pgTable( index('log_entry_deploymentId_idx').on(table.deploymentId), index('log_entry_consumerId_idx').on(table.consumerId), index('log_entry_createdAt_idx').on(table.createdAt), - index('log_entry_updatedAt_idx').on(table.updatedAt) + index('log_entry_updatedAt_idx').on(table.updatedAt), + index('log_entry_deletedAt_idx').on(table.deletedAt) ] ) diff --git a/packages/db/src/schema/project.ts b/packages/db/src/schema/project.ts index c805f7c3..04c2a13c 100644 --- a/packages/db/src/schema/project.ts +++ b/packages/db/src/schema/project.ts @@ -126,7 +126,8 @@ export const projects = pgTable( index('project_teamId_idx').on(table.teamId), index('project_alias_idx').on(table.alias), index('project_createdAt_idx').on(table.createdAt), - index('project_updatedAt_idx').on(table.updatedAt) + index('project_updatedAt_idx').on(table.updatedAt), + index('project_deletedAt_idx').on(table.deletedAt) ] ) diff --git a/packages/db/src/schema/team-member.ts b/packages/db/src/schema/team-member.ts index 7e7b0b7f..20b2289e 100644 --- a/packages/db/src/schema/team-member.ts +++ b/packages/db/src/schema/team-member.ts @@ -44,7 +44,8 @@ export const teamMembers = pgTable( index('team_member_team_idx').on(table.teamId), index('team_member_slug_idx').on(table.teamSlug), index('team_member_createdAt_idx').on(table.createdAt), - index('team_member_updatedAt_idx').on(table.updatedAt) + index('team_member_updatedAt_idx').on(table.updatedAt), + index('team_member_deletedAt_idx').on(table.deletedAt) ] ) diff --git a/packages/db/src/schema/team.ts b/packages/db/src/schema/team.ts index f0dbd474..58c8f5b7 100644 --- a/packages/db/src/schema/team.ts +++ b/packages/db/src/schema/team.ts @@ -33,7 +33,8 @@ export const teams = pgTable( (table) => [ uniqueIndex('team_slug_idx').on(table.slug), index('team_createdAt_idx').on(table.createdAt), - index('team_updatedAt_idx').on(table.updatedAt) + index('team_updatedAt_idx').on(table.updatedAt), + index('team_deletedAt_idx').on(table.deletedAt) ] ) diff --git a/packages/db/src/schema/user.ts b/packages/db/src/schema/user.ts index 4eeffb82..fc557ccd 100644 --- a/packages/db/src/schema/user.ts +++ b/packages/db/src/schema/user.ts @@ -60,7 +60,8 @@ export const users = pgTable( uniqueIndex('user_emailConfirmToken_idx').on(table.emailConfirmToken), uniqueIndex('user_passwordResetToken_idx').on(table.passwordResetToken), index('user_createdAt_idx').on(table.createdAt), - index('user_updatedAt_idx').on(table.updatedAt) + index('user_updatedAt_idx').on(table.updatedAt), + index('user_deletedAt_idx').on(table.deletedAt) ] ) diff --git a/packages/db/src/types.test.ts b/packages/db/src/types.test.ts index 857cb1df..a8cf2f94 100644 --- a/packages/db/src/types.test.ts +++ b/packages/db/src/types.test.ts @@ -1,9 +1,18 @@ import { expectTypeOf, test } from 'vitest' -import type { LogEntry, RawLogEntry, RawUser, User } from './types' +import type { + Consumer, + LogEntry, + RawConsumer, + RawConsumerUpdate, + RawLogEntry, + RawUser, + User +} from './types' type UserKeys = Exclude type LogEntryKeys = keyof RawLogEntry & keyof LogEntry +type ConsumerKeys = keyof RawConsumer & keyof Consumer test('User types are compatible', () => { expectTypeOf().toExtend() @@ -18,3 +27,17 @@ test('LogEntry types are compatible', () => { RawLogEntry[LogEntryKeys] >() }) + +test('Consumer types are compatible', () => { + expectTypeOf().toExtend() + + expectTypeOf().toEqualTypeOf< + RawConsumer[ConsumerKeys] + >() + + // Ensure that we can pass any Consumer as a RawConsumerUpdate + expectTypeOf().toExtend() + + // Ensure that we can pass any RawConsumer as a RawConsumerUpdate + expectTypeOf().toExtend() +}) diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index c6ded1e6..e1ab21fc 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -68,7 +68,7 @@ export type RawConsumer = Simplify< deployment?: RawDeployment | null // TODO: remove null (requires drizzle-orm changes) } > -export type ConsumerUpdate = Partial< +export type RawConsumerUpdate = Partial< Omit< InferInsertModel, 'id' | 'projectId' | 'userId' | 'deploymentId' diff --git a/readme.md b/readme.md index e89ed0b1..37ec2464 100644 --- a/readme.md +++ b/readme.md @@ -10,6 +10,7 @@ - **replace Project.id and Deployment.id with cuids** - move others to `alias` or `publicIdentifier`? - won't work with hono routing? test this +- add prefixes to model ids --- @@ -27,8 +28,6 @@ - https://github.com/NangoHQ/nango - https://github.com/transitive-bullshit?submit=Search&q=oauth&tab=stars&type=&sort=&direction=&submit=Search - clerk / workos / auth0 -- db - - replace `enabled` with soft `deletedAt` timestamp - consider switching to [consola](https://github.com/unjs/consola) for logging? ## License