kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
pull/715/head
rodzic
dd51230849
commit
f4546c2218
|
@ -52,6 +52,7 @@
|
|||
"exit-hook": "catalog:",
|
||||
"hono": "^4.7.7",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"p-all": "^5.0.0",
|
||||
"pino": "^9.6.0",
|
||||
"pino-abstract-transport": "^2.0.0",
|
||||
"postgres": "^3.4.5",
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { aclAdmin } from '@/lib/acl-admin'
|
||||
import { assert, parseZodSchema } from '@/lib/utils'
|
||||
|
||||
import { consumerTokenParamsSchema, populateConsumerSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Gets a consumer',
|
||||
tags: ['consumers'],
|
||||
operationId: 'getConsumer',
|
||||
method: 'get',
|
||||
path: 'admin/consumers/tokens/{token}',
|
||||
security: [{ bearerAuth: [] }],
|
||||
request: {
|
||||
params: consumerTokenParamsSchema,
|
||||
query: populateConsumerSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A consumer object',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.consumerSelectSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO
|
||||
// ...openApiErrorResponses
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1AdminConsumersGetConsumerByToken(
|
||||
app: OpenAPIHono<AuthenticatedEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { token } = c.req.valid('param')
|
||||
const { populate = [] } = c.req.valid('query')
|
||||
await aclAdmin(c)
|
||||
|
||||
const consumer = await db.query.consumers.findFirst({
|
||||
where: eq(schema.consumers.token, token),
|
||||
with: {
|
||||
...Object.fromEntries(populate.map((field) => [field, true]))
|
||||
}
|
||||
})
|
||||
assert(consumer, 404, `Consumer token not found "${token}"`)
|
||||
|
||||
return c.json(parseZodSchema(schema.consumerSelectSchema, consumer))
|
||||
})
|
||||
}
|
|
@ -5,7 +5,7 @@ import { db, eq, schema } from '@/db'
|
|||
import { acl } from '@/lib/acl'
|
||||
import { assert, parseZodSchema } from '@/lib/utils'
|
||||
|
||||
import { consumerIdParamsSchema } from './schemas'
|
||||
import { consumerIdParamsSchema, populateConsumerSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Gets a consumer',
|
||||
|
@ -15,7 +15,8 @@ const route = createRoute({
|
|||
path: 'consumers/{consumersId}',
|
||||
security: [{ bearerAuth: [] }],
|
||||
request: {
|
||||
params: consumerIdParamsSchema
|
||||
params: consumerIdParamsSchema,
|
||||
query: populateConsumerSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
|
@ -36,9 +37,13 @@ export function registerV1ConsumersGetConsumer(
|
|||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { consumerId } = c.req.valid('param')
|
||||
const { populate = [] } = c.req.valid('query')
|
||||
|
||||
const consumer = await db.query.consumers.findFirst({
|
||||
where: eq(schema.consumers.id, consumerId)
|
||||
where: eq(schema.consumers.id, consumerId),
|
||||
with: {
|
||||
...Object.fromEntries(populate.map((field) => [field, true]))
|
||||
}
|
||||
})
|
||||
assert(consumer, 404, `Consumer not found "${consumerId}"`)
|
||||
await acl(c, consumer, { label: 'Consumer' })
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import { assert, parseZodSchema } from '@/lib/utils'
|
||||
|
||||
import { projectIdParamsSchema } from '../projects/schemas'
|
||||
import { paginationAndPopulateConsumerSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Lists consumers (customers) for a project.',
|
||||
tags: ['consumers'],
|
||||
operationId: 'listConsumers',
|
||||
method: 'get',
|
||||
path: 'projects/{projectId}/consumers',
|
||||
security: [{ bearerAuth: [] }],
|
||||
request: {
|
||||
params: projectIdParamsSchema,
|
||||
query: paginationAndPopulateConsumerSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A list of consumers',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.array(schema.consumerSelectSchema)
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO
|
||||
// ...openApiErrorResponses
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1ProjectsListConsumers(
|
||||
app: OpenAPIHono<AuthenticatedEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const {
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
sort = 'desc',
|
||||
sortBy = 'createdAt',
|
||||
populate = []
|
||||
} = c.req.valid('query')
|
||||
|
||||
const { projectId } = c.req.valid('param')
|
||||
assert(projectId, 400, 'Project ID is required')
|
||||
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: eq(schema.projects.id, projectId)
|
||||
})
|
||||
assert(project, 404, `Project not found "${projectId}"`)
|
||||
await acl(c, project, { label: 'Project' })
|
||||
|
||||
const consumers = await db.query.consumers.findMany({
|
||||
where: eq(schema.consumers.projectId, projectId),
|
||||
with: {
|
||||
...Object.fromEntries(populate.map((field) => [field, true]))
|
||||
},
|
||||
orderBy: (consumers, { asc, desc }) => [
|
||||
sort === 'desc' ? desc(consumers[sortBy]) : asc(consumers[sortBy])
|
||||
],
|
||||
offset,
|
||||
limit
|
||||
})
|
||||
|
||||
return c.json(
|
||||
parseZodSchema(z.array(schema.consumerSelectSchema), consumers)
|
||||
)
|
||||
})
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from '@hono/zod-openapi'
|
||||
|
||||
import { consumerIdSchema } from '@/db'
|
||||
import { consumerIdSchema, paginationSchema } from '@/db'
|
||||
import { consumerRelationsSchema } from '@/db/schema'
|
||||
|
||||
export const consumerIdParamsSchema = z.object({
|
||||
consumerId: consumerIdSchema.openapi({
|
||||
|
@ -11,3 +12,25 @@ export const consumerIdParamsSchema = z.object({
|
|||
}
|
||||
})
|
||||
})
|
||||
|
||||
export const consumerTokenParamsSchema = z.object({
|
||||
token: z
|
||||
.string()
|
||||
.nonempty()
|
||||
.openapi({
|
||||
param: {
|
||||
description: 'Consumer token',
|
||||
name: 'token',
|
||||
in: 'path'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export const populateConsumerSchema = z.object({
|
||||
populate: z.array(consumerRelationsSchema).default([]).optional()
|
||||
})
|
||||
|
||||
export const paginationAndPopulateConsumerSchema = z.object({
|
||||
...paginationSchema.shape,
|
||||
...populateConsumerSchema.shape
|
||||
})
|
||||
|
|
|
@ -0,0 +1,142 @@
|
|||
import { parseFaasIdentifier } from '@agentic/validators'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedEnv } from '@/lib/types'
|
||||
import { and, db, eq, schema } from '@/db'
|
||||
import { upsertStripeConnectCustomer } from '@/lib/billing/upsert-stripe-connect-customer'
|
||||
import { upsertStripeCustomer } from '@/lib/billing/upsert-stripe-customer'
|
||||
import { upsertStripePricingPlans } from '@/lib/billing/upsert-stripe-pricing-plans'
|
||||
import { upsertStripeSubscription } from '@/lib/billing/upsert-stripe-subscription'
|
||||
import { assert, parseZodSchema, sha256 } from '@/lib/utils'
|
||||
|
||||
const route = createRoute({
|
||||
description:
|
||||
'Upserts a consumer (customer), subscribing to a specific deployment within project.',
|
||||
tags: ['consumers'],
|
||||
operationId: 'createConsumer',
|
||||
method: 'post',
|
||||
path: 'consumers',
|
||||
security: [{ bearerAuth: [] }],
|
||||
request: {
|
||||
body: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.consumerInsertSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A consumer object',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.consumerSelectSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO
|
||||
// ...openApiErrorResponses
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1ConsumersUpsertConsumer(
|
||||
app: OpenAPIHono<AuthenticatedEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const body = c.req.valid('json')
|
||||
const userId = c.get('userId')
|
||||
|
||||
const parsedIds = parseFaasIdentifier(body.deploymentId)
|
||||
assert(parsedIds, 400, 'Invalid "deploymentId"')
|
||||
const { projectId } = parsedIds
|
||||
|
||||
const [{ user, stripeCustomer }, existing] = await Promise.all([
|
||||
upsertStripeCustomer(c),
|
||||
|
||||
db.query.consumers.findFirst({
|
||||
where: and(
|
||||
eq(schema.consumers.userId, userId),
|
||||
eq(schema.consumers.projectId, projectId)
|
||||
)
|
||||
})
|
||||
])
|
||||
|
||||
assert(
|
||||
!existing ||
|
||||
!existing.enabled ||
|
||||
existing.plan !== body.plan ||
|
||||
existing.deploymentId !== body.deploymentId,
|
||||
409,
|
||||
`User "${user.email}" already has an active subscription to plan "${body.plan}" for project "${projectId}"`
|
||||
)
|
||||
|
||||
const deployment = await db.query.deployments.findFirst({
|
||||
where: eq(schema.deployments.id, body.deploymentId),
|
||||
with: {
|
||||
project: true
|
||||
}
|
||||
})
|
||||
assert(deployment, 404, `Deployment not found "${body.deploymentId}"`)
|
||||
|
||||
const { project } = deployment
|
||||
assert(
|
||||
project,
|
||||
404,
|
||||
`Project not found "${projectId}" for deployment "${body.deploymentId}"`
|
||||
)
|
||||
assert(
|
||||
deployment.enabled,
|
||||
410,
|
||||
`Deployment has been disabled by its owner "${deployment.id}"`
|
||||
)
|
||||
|
||||
let consumer = existing
|
||||
|
||||
if (consumer) {
|
||||
consumer.plan = body.plan
|
||||
consumer.deploymentId = body.deploymentId
|
||||
;[consumer] = await db
|
||||
.update(schema.consumers)
|
||||
.set(consumer)
|
||||
.where(eq(schema.consumers.id, consumer.id))
|
||||
.returning()
|
||||
} else {
|
||||
;[consumer] = await db.insert(schema.consumers).values({
|
||||
...body,
|
||||
userId,
|
||||
projectId,
|
||||
token: sha256().slice(0, 24),
|
||||
_stripeCustomerId: stripeCustomer.id
|
||||
})
|
||||
}
|
||||
|
||||
assert(consumer, 500, 'Error creating consumer')
|
||||
|
||||
// make sure all pricing plans exist
|
||||
await upsertStripePricingPlans({ deployment, project })
|
||||
|
||||
// make sure that customer and default source are created on stripe connect acct
|
||||
// TODO: is this necessary?
|
||||
// consumer._stripeAccount = project._stripeAccount
|
||||
await upsertStripeConnectCustomer({ stripeCustomer, consumer, project })
|
||||
|
||||
console.log('SUBSCRIPTION', existing ? 'UPDATE' : 'CREATE', {
|
||||
project,
|
||||
deployment,
|
||||
consumer
|
||||
})
|
||||
|
||||
const { subscription, consumer: updatedConsumer } =
|
||||
await upsertStripeSubscription({
|
||||
consumer,
|
||||
user,
|
||||
project,
|
||||
deployment
|
||||
})
|
||||
console.log({ subscription })
|
||||
|
||||
return c.json(parseZodSchema(schema.consumerSelectSchema, updatedConsumer))
|
||||
})
|
||||
}
|
|
@ -3,6 +3,7 @@ import { OpenAPIHono } from '@hono/zod-openapi'
|
|||
import type { AuthenticatedEnv } from '@/lib/types'
|
||||
import * as middleware from '@/lib/middleware'
|
||||
|
||||
import { registerV1AdminConsumersGetConsumerByToken } from './consumers/admin-get-consumer-by-token'
|
||||
import { registerV1ConsumersGetConsumer } from './consumers/get-consumer'
|
||||
import { registerHealthCheck } from './health-check'
|
||||
import { registerV1ProjectsCreateProject } from './projects/create-project'
|
||||
|
@ -67,9 +68,12 @@ registerV1ProjectsUpdateProject(pri)
|
|||
// Consumers crud
|
||||
registerV1ConsumersGetConsumer(pri)
|
||||
|
||||
// webhook events
|
||||
// Webhook event handlers
|
||||
registerV1StripeWebhook(pub)
|
||||
|
||||
// Admin routes
|
||||
registerV1AdminConsumersGetConsumerByToken(pri)
|
||||
|
||||
// Setup routes and middleware
|
||||
apiV1.route('/', pub)
|
||||
apiV1.use(middleware.authenticate)
|
||||
|
|
|
@ -4,6 +4,7 @@ import type { AuthenticatedEnv } from '@/lib/types'
|
|||
import { db, schema } from '@/db'
|
||||
import { aclTeamMember } from '@/lib/acl-team-member'
|
||||
import { getProviderToken } from '@/lib/auth/get-provider-token'
|
||||
import { ensureAuthUser } from '@/lib/ensure-auth-user'
|
||||
import { assert, parseZodSchema, sha256 } from '@/lib/utils'
|
||||
|
||||
const route = createRoute({
|
||||
|
@ -42,7 +43,7 @@ export function registerV1ProjectsCreateProject(
|
|||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const body = c.req.valid('json')
|
||||
const user = c.get('user')
|
||||
const user = await ensureAuthUser(c)
|
||||
|
||||
if (body.teamId) {
|
||||
await aclTeamMember(c, { teamId: body.teamId })
|
||||
|
|
|
@ -2,6 +2,7 @@ import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
|
|||
|
||||
import type { AuthenticatedEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { ensureAuthUser } from '@/lib/ensure-auth-user'
|
||||
import { parseZodSchema } from '@/lib/utils'
|
||||
|
||||
import { paginationAndPopulateProjectSchema } from './schemas'
|
||||
|
@ -42,7 +43,7 @@ export function registerV1ProjectsListProjects(
|
|||
populate = []
|
||||
} = c.req.valid('query')
|
||||
|
||||
const user = c.get('user')
|
||||
const user = await ensureAuthUser(c)
|
||||
const teamMember = c.get('teamMember')
|
||||
const isAdmin = user.role === 'admin'
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
|||
|
||||
import type { AuthenticatedEnv } from '@/lib/types'
|
||||
import { db, schema } from '@/db'
|
||||
import { ensureAuthUser } from '@/lib/ensure-auth-user'
|
||||
import { ensureUniqueTeamSlug } from '@/lib/ensure-unique-team-slug'
|
||||
import { assert, parseZodSchema } from '@/lib/utils'
|
||||
|
||||
|
@ -38,7 +39,7 @@ const route = createRoute({
|
|||
|
||||
export function registerV1TeamsCreateTeam(app: OpenAPIHono<AuthenticatedEnv>) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const user = c.get('user')
|
||||
const user = await ensureAuthUser(c)
|
||||
const body = c.req.valid('json')
|
||||
|
||||
await ensureUniqueTeamSlug(body.slug)
|
||||
|
|
|
@ -36,11 +36,12 @@ export function registerV1TeamsListTeams(app: OpenAPIHono<AuthenticatedEnv>) {
|
|||
sort = 'desc',
|
||||
sortBy = 'createdAt'
|
||||
} = c.req.valid('query')
|
||||
const userId = c.get('userId')
|
||||
|
||||
// schema.teamMembers._.columns
|
||||
|
||||
const teamMembers = await db.query.teamMembers.findMany({
|
||||
where: eq(schema.teamMembers.userId, c.get('user').id),
|
||||
where: eq(schema.teamMembers.userId, userId),
|
||||
with: {
|
||||
team: true
|
||||
},
|
||||
|
|
|
@ -67,7 +67,7 @@ export const consumers = pgTable(
|
|||
// stripe subscription status (synced via webhooks)
|
||||
stripeStatus: text(),
|
||||
|
||||
stripeSubscriptionId: stripeId().notNull(),
|
||||
stripeSubscriptionId: stripeId(),
|
||||
stripeSubscriptionBaseItemId: stripeId(),
|
||||
stripeSubscriptionRequestItemId: stripeId(),
|
||||
|
||||
|
@ -107,7 +107,16 @@ export const consumersRelations = relations(consumers, ({ one }) => ({
|
|||
})
|
||||
}))
|
||||
|
||||
export const consumerSelectSchema = createSelectSchema(consumers)
|
||||
export type ConsumerRelationFields = keyof ReturnType<
|
||||
(typeof consumersRelations)['config']
|
||||
>
|
||||
|
||||
export const consumerRelationsSchema: z.ZodType<ConsumerRelationFields> =
|
||||
z.enum(['user', 'project', 'deployment'])
|
||||
|
||||
export const consumerSelectSchema = createSelectSchema(consumers, {
|
||||
stripeSubscriptionMetricItems: z.record(z.string(), z.string())
|
||||
})
|
||||
.omit({
|
||||
_stripeCustomerId: true
|
||||
})
|
||||
|
@ -131,13 +140,10 @@ export const consumerSelectSchema = createSelectSchema(consumers)
|
|||
|
||||
export const consumerInsertSchema = createInsertSchema(consumers)
|
||||
.pick({
|
||||
token: true,
|
||||
plan: true,
|
||||
env: true,
|
||||
coupon: true,
|
||||
source: true,
|
||||
userId: true,
|
||||
projectId: true,
|
||||
deploymentId: true
|
||||
})
|
||||
.strict()
|
||||
|
|
|
@ -66,6 +66,7 @@ export const projects = pgTable(
|
|||
stripeBaseProductId: stripeId(),
|
||||
stripeRequestProductId: stripeId(),
|
||||
|
||||
// Map between metric slugs and stripe product ids
|
||||
// [metricSlug: string]: string
|
||||
stripeMetricProductIds: jsonb()
|
||||
.$type<Record<string, string>>()
|
||||
|
|
|
@ -60,7 +60,7 @@ export const pricingPlanTierSchema = z
|
|||
.object({
|
||||
unitAmount: z.number().optional(),
|
||||
flatAmount: z.number().optional(),
|
||||
upTo: z.string()
|
||||
upTo: z.union([z.number(), z.literal('inf')])
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
|
@ -116,10 +116,10 @@ export const pricingPlanSchema = z
|
|||
|
||||
rateLimit: rateLimitSchema.optional(),
|
||||
|
||||
// used to uniquely identify this plan across deployments
|
||||
// used to uniquely identify this pricing plan across deployments
|
||||
baseId: z.string(),
|
||||
|
||||
// used to uniquely identify this plan across deployments
|
||||
// used to uniquely identify this pricing plan across deployments
|
||||
requestsId: z.string(),
|
||||
|
||||
// [metricSlug: string]: string
|
||||
|
@ -128,9 +128,10 @@ export const pricingPlanSchema = z
|
|||
// NOTE: the stripe billing plan id(s) for this PricingPlan are referenced
|
||||
// in the Project._stripePlans mapping via the plan's hash.
|
||||
// NOTE: all metered billing usage is stored in stripe
|
||||
stripeBasePlan: z.string(),
|
||||
stripeRequestPlan: z.string(),
|
||||
stripeBasePlanId: z.string(),
|
||||
stripeRequestPlanId: z.string(),
|
||||
|
||||
// Record mapping metric slugs to stripe plan IDs
|
||||
// [metricSlug: string]: string
|
||||
stripeMetricPlans: z.record(z.string())
|
||||
})
|
||||
|
|
|
@ -1,14 +1,19 @@
|
|||
import type {
|
||||
BuildQueryResult,
|
||||
ExtractTablesWithRelations
|
||||
ExtractTablesWithRelations,
|
||||
InferInsertModel,
|
||||
InferSelectModel
|
||||
} from '@fisch0920/drizzle-orm'
|
||||
import type { z } from '@hono/zod-openapi'
|
||||
|
||||
import type { UndefinedToNullDeep } from '@/lib/types'
|
||||
|
||||
import type * as schema from './schema'
|
||||
|
||||
export type Tables = ExtractTablesWithRelations<typeof schema>
|
||||
|
||||
export type User = z.infer<typeof schema.userSelectSchema>
|
||||
export type RawUser = InferSelectModel<typeof schema.users>
|
||||
|
||||
export type Team = z.infer<typeof schema.teamSelectSchema>
|
||||
export type TeamWithMembers = BuildQueryResult<
|
||||
|
@ -16,6 +21,7 @@ export type TeamWithMembers = BuildQueryResult<
|
|||
Tables['teams'],
|
||||
{ with: { members: true } }
|
||||
>
|
||||
export type RawTeam = InferSelectModel<typeof schema.teams>
|
||||
|
||||
export type TeamMember = z.infer<typeof schema.teamMemberSelectSchema>
|
||||
export type TeamMemberWithTeam = BuildQueryResult<
|
||||
|
@ -23,6 +29,7 @@ export type TeamMemberWithTeam = BuildQueryResult<
|
|||
Tables['teamMembers'],
|
||||
{ with: { team: true } }
|
||||
>
|
||||
export type RawTeamMember = InferSelectModel<typeof schema.teamMembers>
|
||||
|
||||
export type Project = z.infer<typeof schema.projectSelectSchema>
|
||||
export type ProjectWithLastPublishedDeployment = BuildQueryResult<
|
||||
|
@ -30,6 +37,7 @@ export type ProjectWithLastPublishedDeployment = BuildQueryResult<
|
|||
Tables['projects'],
|
||||
{ with: { lastPublishedDeployment: true } }
|
||||
>
|
||||
export type RawProject = InferSelectModel<typeof schema.projects>
|
||||
|
||||
export type Deployment = z.infer<typeof schema.deploymentSelectSchema>
|
||||
export type DeploymentWithProject = BuildQueryResult<
|
||||
|
@ -37,6 +45,7 @@ export type DeploymentWithProject = BuildQueryResult<
|
|||
Tables['deployments'],
|
||||
{ with: { project: true } }
|
||||
>
|
||||
export type RawDeployment = InferSelectModel<typeof schema.deployments>
|
||||
|
||||
export type Consumer = z.infer<typeof schema.consumerSelectSchema>
|
||||
export type ConsumerWithProjectAndDeployment = BuildQueryResult<
|
||||
|
@ -44,5 +53,15 @@ export type ConsumerWithProjectAndDeployment = BuildQueryResult<
|
|||
Tables['consumers'],
|
||||
{ with: { project: true; deployment: true } }
|
||||
>
|
||||
export type RawConsumer = InferSelectModel<typeof schema.consumers>
|
||||
export type ConsumerUpdate = Partial<
|
||||
UndefinedToNullDeep<
|
||||
Omit<
|
||||
InferInsertModel<typeof schema.consumers>,
|
||||
'id' | 'projectId' | 'userId' | 'deploymentId'
|
||||
>
|
||||
>
|
||||
>
|
||||
|
||||
export type LogEntry = z.infer<typeof schema.logEntrySelectSchema>
|
||||
export type RawLogEntry = InferSelectModel<typeof schema.logEntries>
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import type { AuthenticatedContext } from './types'
|
||||
import { ensureAuthUser } from './ensure-auth-user'
|
||||
import { assert } from './utils'
|
||||
|
||||
export async function aclAdmin(ctx: AuthenticatedContext) {
|
||||
const user = ctx.get('user')
|
||||
const user = await ensureAuthUser(ctx)
|
||||
assert(user, 401, 'Authentication required')
|
||||
assert(user.role === 'admin', 403, 'Access denied')
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { and, db, eq, schema, type TeamMember } from '@/db'
|
||||
|
||||
import type { AuthenticatedContext } from './types'
|
||||
import { ensureAuthUser } from './ensure-auth-user'
|
||||
import { assert } from './utils'
|
||||
|
||||
export async function aclTeamAdmin(
|
||||
|
@ -13,8 +14,7 @@ export async function aclTeamAdmin(
|
|||
teamMember?: TeamMember
|
||||
}
|
||||
) {
|
||||
const user = ctx.get('user')
|
||||
assert(user, 401, 'Authentication required')
|
||||
const user = await ensureAuthUser(ctx)
|
||||
|
||||
if (user.role === 'admin') {
|
||||
// TODO: Allow admins to access all team resources
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { and, db, eq, schema, type TeamMember } from '@/db'
|
||||
import { and, db, eq, type RawTeamMember, schema } from '@/db'
|
||||
|
||||
import type { AuthenticatedContext } from './types'
|
||||
import { ensureAuthUser } from './ensure-auth-user'
|
||||
import { assert } from './utils'
|
||||
|
||||
export async function aclTeamMember(
|
||||
|
@ -13,12 +14,15 @@ export async function aclTeamMember(
|
|||
}: {
|
||||
teamSlug?: string
|
||||
teamId?: string
|
||||
teamMember?: TeamMember
|
||||
teamMember?: RawTeamMember
|
||||
userId?: string
|
||||
} & ({ teamSlug: string } | { teamId: string } | { teamMember: TeamMember })
|
||||
} & (
|
||||
| { teamSlug: string }
|
||||
| { teamId: string }
|
||||
| { teamMember: RawTeamMember }
|
||||
)
|
||||
) {
|
||||
const user = ctx.get('user')
|
||||
assert(user, 401, 'Authentication required')
|
||||
const user = await ensureAuthUser(ctx)
|
||||
assert(teamSlug || teamId, 500, 'Either teamSlug or teamId must be provided')
|
||||
|
||||
if (user.role === 'admin') {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import type { AuthenticatedContext } from './types'
|
||||
import { ensureAuthUser } from './ensure-auth-user'
|
||||
import { assert } from './utils'
|
||||
|
||||
export async function acl<
|
||||
|
@ -18,9 +19,7 @@ export async function acl<
|
|||
teamField?: TTeamField
|
||||
}
|
||||
) {
|
||||
const user = ctx.get('user')
|
||||
assert(user, 401, 'Authentication required')
|
||||
|
||||
const user = await ensureAuthUser(ctx)
|
||||
const teamMember = ctx.get('teamMember')
|
||||
|
||||
const userFieldValue = model[userField]
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
import type Stripe from 'stripe'
|
||||
|
||||
import { db, eq, type RawConsumer, type RawProject, schema } from '@/db'
|
||||
import { stripe } from '@/lib/stripe'
|
||||
import { assert } from '@/lib/utils'
|
||||
|
||||
export async function upsertStripeConnectCustomer({
|
||||
stripeCustomer,
|
||||
consumer,
|
||||
project
|
||||
}: {
|
||||
stripeCustomer: Stripe.Customer
|
||||
consumer: RawConsumer
|
||||
project: RawProject
|
||||
}): Promise<Stripe.Customer | undefined> {
|
||||
if (!project._stripeAccountId) {
|
||||
return stripeCustomer
|
||||
}
|
||||
|
||||
const stripeConnectParams = project._stripeAccountId
|
||||
? [
|
||||
{
|
||||
stripeAccount: project._stripeAccountId
|
||||
}
|
||||
]
|
||||
: []
|
||||
|
||||
const stripeConnectCustomer = consumer._stripeCustomerId
|
||||
? await stripe.customers.retrieve(
|
||||
consumer._stripeCustomerId,
|
||||
...stripeConnectParams
|
||||
)
|
||||
: await stripe.customers.create(
|
||||
{
|
||||
email: stripeCustomer.email!,
|
||||
metadata: stripeCustomer.metadata
|
||||
},
|
||||
...stripeConnectParams
|
||||
)
|
||||
assert(
|
||||
stripeConnectCustomer,
|
||||
500,
|
||||
`Failed to create stripe connect customer for user "${consumer.userId}"`
|
||||
)
|
||||
assert(
|
||||
!stripeConnectCustomer.deleted,
|
||||
500,
|
||||
`Stripe connect customer "${stripeConnectCustomer.id}" has been deleted`
|
||||
)
|
||||
|
||||
if (consumer._stripeCustomerId !== stripeConnectCustomer.id) {
|
||||
consumer._stripeCustomerId = stripeConnectCustomer.id
|
||||
|
||||
await db
|
||||
.update(schema.consumers)
|
||||
.set({ _stripeCustomerId: stripeConnectCustomer.id })
|
||||
.where(eq(schema.consumers.id, consumer.id))
|
||||
}
|
||||
|
||||
// TODO: Ensure stripe connect default "source" exists and is cloned from
|
||||
// platform stripe account.
|
||||
|
||||
return stripeConnectCustomer
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
import type Stripe from 'stripe'
|
||||
|
||||
import type { AuthenticatedContext } from '@/lib/types'
|
||||
import { db, eq, type RawUser, schema } from '@/db'
|
||||
import { ensureAuthUser } from '@/lib/ensure-auth-user'
|
||||
import { stripe } from '@/lib/stripe'
|
||||
import { assert } from '@/lib/utils'
|
||||
|
||||
export async function upsertStripeCustomer(ctx: AuthenticatedContext): Promise<{
|
||||
user: RawUser
|
||||
stripeCustomer: Stripe.Customer
|
||||
}> {
|
||||
const user = await ensureAuthUser(ctx)
|
||||
|
||||
if (user.stripeCustomerId) {
|
||||
const stripeCustomer = await stripe.customers.retrieve(
|
||||
user.stripeCustomerId
|
||||
)
|
||||
assert(
|
||||
stripeCustomer,
|
||||
404,
|
||||
`Stripe customer "${user.stripeCustomerId}" not found for user "${user.id}"`
|
||||
)
|
||||
|
||||
// TODO: handle this edge case
|
||||
assert(
|
||||
!stripeCustomer.deleted,
|
||||
404,
|
||||
`Stripe customer "${user.stripeCustomerId}" is deleted for user "${user.id}"`
|
||||
)
|
||||
|
||||
return {
|
||||
user,
|
||||
stripeCustomer
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: add more metadata referencing signup LogEntry
|
||||
const metadata = {
|
||||
userId: user.id,
|
||||
username: user.username
|
||||
}
|
||||
|
||||
const stripeCustomer = await stripe.customers.create({
|
||||
email: user.email,
|
||||
metadata
|
||||
})
|
||||
assert(
|
||||
stripeCustomer,
|
||||
500,
|
||||
`Failed to create stripe customer for user "${user.id}"`
|
||||
)
|
||||
|
||||
user.stripeCustomerId = stripeCustomer.id
|
||||
await db
|
||||
.update(schema.users)
|
||||
.set({ stripeCustomerId: stripeCustomer.id })
|
||||
.where(eq(schema.users.id, user.id))
|
||||
|
||||
return {
|
||||
user,
|
||||
stripeCustomer
|
||||
}
|
||||
}
|
|
@ -0,0 +1,254 @@
|
|||
import type Stripe from 'stripe'
|
||||
import pAll from 'p-all'
|
||||
|
||||
import type { PricingPlan, PricingPlanMetric } from '@/db/schema'
|
||||
import { db, eq, type RawDeployment, type RawProject, schema } from '@/db'
|
||||
import { stripe } from '@/lib/stripe'
|
||||
import { assert } from '@/lib/utils'
|
||||
|
||||
// TODO: move these to config
|
||||
const currency = 'usd'
|
||||
const interval = 'month'
|
||||
|
||||
export async function upsertStripePricingPlans({
|
||||
deployment,
|
||||
project
|
||||
}: {
|
||||
deployment: RawDeployment
|
||||
project: RawProject
|
||||
}): Promise<void> {
|
||||
const stripeConnectParams = project._stripeAccountId
|
||||
? [
|
||||
{
|
||||
stripeAccount: project._stripeAccountId
|
||||
}
|
||||
]
|
||||
: []
|
||||
let dirty = false
|
||||
|
||||
async function upsertStripeBaseProduct() {
|
||||
if (!project.stripeBaseProductId) {
|
||||
const product = await stripe.products.create(
|
||||
{
|
||||
name: `${project.id} base`,
|
||||
type: 'service'
|
||||
},
|
||||
...stripeConnectParams
|
||||
)
|
||||
|
||||
project.stripeBaseProductId = product.id
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertStripeRequestProduct() {
|
||||
if (!project.stripeRequestProductId) {
|
||||
const product = await stripe.products.create(
|
||||
{
|
||||
name: `${project.id} requests`,
|
||||
type: 'service',
|
||||
unit_label: 'request'
|
||||
},
|
||||
...stripeConnectParams
|
||||
)
|
||||
|
||||
project.stripeRequestProductId = product.id
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertStripeMetricProduct(metric: PricingPlanMetric) {
|
||||
const { slug: metricSlug } = metric
|
||||
|
||||
if (!project.stripeMetricProductIds[metricSlug]) {
|
||||
const product = await stripe.products.create(
|
||||
{
|
||||
name: `${project.id} ${metricSlug}`,
|
||||
type: 'service',
|
||||
unit_label: metric.unitLabel
|
||||
},
|
||||
...stripeConnectParams
|
||||
)
|
||||
|
||||
project.stripeMetricProductIds[metricSlug] = product.id
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertStripeBasePlan(pricingPlan: PricingPlan) {
|
||||
if (!pricingPlan.stripeBasePlanId) {
|
||||
const hash = pricingPlan.baseId
|
||||
const stripePlan = project._stripePlanIds[hash]
|
||||
assert(stripePlan, 400, 'Missing stripe base plan')
|
||||
|
||||
pricingPlan.stripeBasePlanId = stripePlan.basePlanId
|
||||
dirty = true
|
||||
|
||||
if (!pricingPlan.stripeBasePlanId) {
|
||||
const stripePlan = await stripe.plans.create(
|
||||
{
|
||||
product: project.stripeBaseProductId,
|
||||
currency,
|
||||
interval,
|
||||
amount_decimal: pricingPlan.amount.toFixed(12),
|
||||
nickname: `${project.id}-${pricingPlan.slug}-base`
|
||||
},
|
||||
...stripeConnectParams
|
||||
)
|
||||
|
||||
pricingPlan.stripeBasePlanId = stripePlan.id
|
||||
project._stripePlanIds[hash]!.basePlanId = stripePlan.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertStripeRequestPlan(pricingPlan: PricingPlan) {
|
||||
const { requests } = pricingPlan
|
||||
|
||||
if (!pricingPlan.stripeRequestPlanId) {
|
||||
const hash = pricingPlan.requestsId
|
||||
const projectStripePlan = project._stripePlanIds[hash]
|
||||
assert(projectStripePlan, 400, 'Missing stripe request plan')
|
||||
|
||||
pricingPlan.stripeRequestPlanId = projectStripePlan.requestPlanId
|
||||
dirty = true
|
||||
|
||||
if (!pricingPlan.stripeRequestPlanId) {
|
||||
const planParams: Stripe.PlanCreateParams = {
|
||||
product: project.stripeRequestProductId,
|
||||
currency,
|
||||
interval,
|
||||
usage_type: 'metered',
|
||||
billing_scheme: requests.billingScheme,
|
||||
nickname: `${project.id}-${pricingPlan.slug}-requests`
|
||||
}
|
||||
|
||||
if (requests.billingScheme === 'tiered') {
|
||||
planParams.tiers_mode = requests.tiersMode
|
||||
planParams.tiers = requests.tiers.map((tier) => {
|
||||
const result: Stripe.PlanCreateParams.Tier = {
|
||||
up_to: tier.upTo
|
||||
}
|
||||
|
||||
if (tier.unitAmount !== undefined) {
|
||||
result.unit_amount_decimal = tier.unitAmount.toFixed(12)
|
||||
}
|
||||
|
||||
if (tier.flatAmount !== undefined) {
|
||||
result.flat_amount_decimal = tier.flatAmount.toFixed(12)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
} else {
|
||||
planParams.amount_decimal = requests.amount.toFixed(12)
|
||||
}
|
||||
|
||||
const stripePlan = await stripe.plans.create(
|
||||
planParams,
|
||||
...stripeConnectParams
|
||||
)
|
||||
|
||||
pricingPlan.stripeRequestPlanId = stripePlan.id
|
||||
projectStripePlan.requestPlanId = stripePlan.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertStripeMetricPlan(
|
||||
pricingPlan: PricingPlan,
|
||||
metric: PricingPlanMetric
|
||||
) {
|
||||
const { slug: metricSlug } = metric
|
||||
|
||||
if (!pricingPlan.stripeMetricPlans[metricSlug]) {
|
||||
const hash = pricingPlan.metricIds[metricSlug]
|
||||
assert(hash, 500, `Missing stripe metric "${metricSlug}"`)
|
||||
|
||||
const projectStripePlan = project._stripePlanIds[hash]
|
||||
assert(projectStripePlan, 500, 'Missing stripe request plan')
|
||||
|
||||
// TODO: is this right? differs from original source
|
||||
pricingPlan.stripeMetricPlans[metricSlug] = projectStripePlan.basePlanId
|
||||
dirty = true
|
||||
|
||||
if (!pricingPlan.stripeMetricPlans[metricSlug]) {
|
||||
const stripeProductId = project.stripeMetricProductIds[metricSlug]
|
||||
assert(
|
||||
stripeProductId,
|
||||
500,
|
||||
`Missing stripe product ID for metric "${metricSlug}"`
|
||||
)
|
||||
|
||||
const planParams: Stripe.PlanCreateParams = {
|
||||
product: stripeProductId,
|
||||
currency,
|
||||
interval,
|
||||
usage_type: metric.usageType,
|
||||
billing_scheme: metric.billingScheme,
|
||||
nickname: `${project.id}-${pricingPlan.slug}-${metricSlug}`
|
||||
}
|
||||
|
||||
if (metric.billingScheme === 'tiered') {
|
||||
planParams.tiers_mode = metric.tiersMode
|
||||
planParams.tiers = metric.tiers.map((tier) => {
|
||||
const result: Stripe.PlanCreateParams.Tier = {
|
||||
up_to: tier.upTo
|
||||
}
|
||||
|
||||
if (tier.unitAmount !== undefined) {
|
||||
result.unit_amount_decimal = tier.unitAmount.toFixed(12)
|
||||
}
|
||||
|
||||
if (tier.flatAmount !== undefined) {
|
||||
result.flat_amount_decimal = tier.flatAmount.toFixed(12)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
} else {
|
||||
planParams.amount_decimal = metric.amount.toFixed(12)
|
||||
}
|
||||
|
||||
const stripePlan = await stripe.plans.create(
|
||||
planParams,
|
||||
...stripeConnectParams
|
||||
)
|
||||
|
||||
pricingPlan.stripeMetricPlans[metricSlug] = stripePlan.id
|
||||
projectStripePlan.basePlanId = stripePlan.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([upsertStripeBaseProduct(), upsertStripeRequestProduct()])
|
||||
|
||||
const upserts = []
|
||||
for (const pricingPlan of deployment.pricingPlans) {
|
||||
upserts.push(() => upsertStripeBasePlan(pricingPlan))
|
||||
upserts.push(() => upsertStripeRequestPlan(pricingPlan))
|
||||
|
||||
for (const metric of pricingPlan.metrics) {
|
||||
upserts.push(async () => {
|
||||
await upsertStripeMetricProduct(metric)
|
||||
return upsertStripeMetricPlan(pricingPlan, metric)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await pAll(upserts, { concurrency: 4 })
|
||||
|
||||
if (dirty) {
|
||||
await Promise.all([
|
||||
db
|
||||
.update(schema.projects)
|
||||
.set(project)
|
||||
.where(eq(schema.projects.id, project.id)),
|
||||
|
||||
db
|
||||
.update(schema.deployments)
|
||||
.set(deployment)
|
||||
.where(eq(schema.deployments.id, deployment.id))
|
||||
])
|
||||
}
|
||||
}
|
|
@ -0,0 +1,369 @@
|
|||
import type Stripe from 'stripe'
|
||||
|
||||
import {
|
||||
type ConsumerUpdate,
|
||||
db,
|
||||
eq,
|
||||
type RawConsumer,
|
||||
type RawDeployment,
|
||||
type RawProject,
|
||||
type RawUser,
|
||||
schema
|
||||
} from '@/db'
|
||||
import { stripe } from '@/lib/stripe'
|
||||
import { assert } from '@/lib/utils'
|
||||
|
||||
export async function upsertStripeSubscription({
|
||||
consumer,
|
||||
user,
|
||||
deployment,
|
||||
project
|
||||
}: {
|
||||
consumer: RawConsumer
|
||||
user: RawUser
|
||||
deployment: RawDeployment
|
||||
project: RawProject
|
||||
}): Promise<{
|
||||
subscription: Stripe.Subscription
|
||||
consumer: RawConsumer
|
||||
}> {
|
||||
const stripeConnectParams = project._stripeAccountId
|
||||
? [
|
||||
{
|
||||
stripeAccount: project._stripeAccountId
|
||||
}
|
||||
]
|
||||
: []
|
||||
|
||||
const stripeCustomerId = consumer._stripeCustomerId || user.stripeCustomerId
|
||||
assert(
|
||||
stripeCustomerId,
|
||||
500,
|
||||
`Missing valid stripe customer. Please contact support for deployment "${deployment.id}" and consumer "${consumer.id}"`
|
||||
)
|
||||
|
||||
const { plan } = consumer
|
||||
const pricingPlan = plan
|
||||
? deployment.pricingPlans.find((pricingPlan) => pricingPlan.slug === plan)
|
||||
: undefined
|
||||
|
||||
const action: 'create' | 'update' | 'cancel' = consumer.stripeSubscriptionId
|
||||
? plan
|
||||
? 'update'
|
||||
: 'cancel'
|
||||
: 'create'
|
||||
let subscription: Stripe.Subscription | undefined
|
||||
|
||||
if (consumer.stripeSubscriptionId) {
|
||||
// customer has an existing subscription
|
||||
const existing = await stripe.subscriptions.retrieve(
|
||||
consumer.stripeSubscriptionId,
|
||||
...stripeConnectParams
|
||||
)
|
||||
const existingItems = existing.items.data
|
||||
console.log()
|
||||
console.log('existing subscription', JSON.stringify(existing, null, 2))
|
||||
console.log()
|
||||
|
||||
const update: Stripe.SubscriptionUpdateParams = {}
|
||||
|
||||
if (plan) {
|
||||
assert(
|
||||
pricingPlan,
|
||||
404,
|
||||
`Unable to update stripe subscription for invalid pricing plan "${plan}"`
|
||||
)
|
||||
|
||||
let items: Stripe.SubscriptionUpdateParams.Item[] = [
|
||||
{
|
||||
plan: pricingPlan.stripeBasePlanId,
|
||||
id: consumer.stripeSubscriptionBaseItemId
|
||||
},
|
||||
{
|
||||
plan: pricingPlan.stripeRequestPlanId,
|
||||
id: consumer.stripeSubscriptionRequestItemId
|
||||
}
|
||||
]
|
||||
|
||||
for (const metric of pricingPlan.metrics) {
|
||||
const { slug: metricSlug } = metric
|
||||
console.log({
|
||||
metricSlug,
|
||||
plan: pricingPlan.stripeMetricPlans[metricSlug],
|
||||
id: consumer.stripeSubscriptionMetricItems[metricSlug]
|
||||
})
|
||||
|
||||
items.push({
|
||||
plan: pricingPlan.stripeMetricPlans[metricSlug]!,
|
||||
id: consumer.stripeSubscriptionMetricItems[metricSlug]
|
||||
})
|
||||
}
|
||||
|
||||
const invalidItems = items.filter((item) => !item.plan)
|
||||
if (plan && invalidItems.length) {
|
||||
console.error('billing warning found invalid items', invalidItems)
|
||||
}
|
||||
|
||||
items = items.filter((item) => item.plan)
|
||||
|
||||
for (const item of items) {
|
||||
if (item.id) {
|
||||
const existingItem = existingItems.find(
|
||||
(existingItem) => item.id === existingItem.id
|
||||
)
|
||||
|
||||
if (!existingItem) {
|
||||
console.error(
|
||||
'billing warning found new item that has a subscription item id but should not',
|
||||
{ item }
|
||||
)
|
||||
delete item.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: We should never use clear_usage because it causes us to lose money.
|
||||
// A customer could downgrade their subscription at the end of a pay period
|
||||
// and this would clear all usage for their period, effectively allowing them
|
||||
// to hack the service for free usage.
|
||||
// The solution to this problem is to always have an equivalent free plan for
|
||||
// every paid plan.
|
||||
|
||||
for (const existingItem of existingItems) {
|
||||
const updatedItem = items.find((item) => item.id === existingItem.id)
|
||||
|
||||
if (!updatedItem) {
|
||||
const deletedItem: Stripe.SubscriptionUpdateParams.Item = {
|
||||
id: existingItem.id,
|
||||
deleted: true
|
||||
}
|
||||
|
||||
if (existingItem.plan.usage_type === 'metered') {
|
||||
deletedItem.clear_usage = true
|
||||
}
|
||||
|
||||
items.push(deletedItem)
|
||||
}
|
||||
}
|
||||
|
||||
assert(
|
||||
items.length || !plan,
|
||||
500,
|
||||
`Error updating stripe subscription "${consumer.stripeSubscriptionId}"`
|
||||
)
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.id) {
|
||||
delete item.id
|
||||
}
|
||||
}
|
||||
|
||||
update.items = items
|
||||
|
||||
if (pricingPlan.trialPeriodDays) {
|
||||
update.trial_end =
|
||||
Math.trunc(Date.now() / 1000) +
|
||||
24 * 60 * 60 * pricingPlan.trialPeriodDays
|
||||
}
|
||||
|
||||
console.log('subscription', action, { items })
|
||||
} else {
|
||||
update.cancel_at_period_end = true
|
||||
}
|
||||
|
||||
if (project.isStripeConnectEnabled && project.applicationFeePercent > 0) {
|
||||
update.application_fee_percent = project.applicationFeePercent
|
||||
}
|
||||
|
||||
subscription = await stripe.subscriptions.update(
|
||||
consumer.stripeSubscriptionId,
|
||||
update,
|
||||
...stripeConnectParams
|
||||
)
|
||||
|
||||
// TODO: this will cancel the subscription without resolving current usage / invoices
|
||||
// await stripe.subscriptions.del(consumer.stripeSubscription)
|
||||
} else {
|
||||
assert(
|
||||
pricingPlan,
|
||||
404,
|
||||
`Unable to update stripe subscription for invalid pricing plan "${plan}"`
|
||||
)
|
||||
|
||||
let items: Stripe.SubscriptionCreateParams.Item[] = [
|
||||
{
|
||||
plan: pricingPlan.stripeBasePlanId
|
||||
},
|
||||
{
|
||||
plan: pricingPlan.stripeRequestPlanId
|
||||
}
|
||||
]
|
||||
|
||||
for (const metric of pricingPlan.metrics) {
|
||||
const { slug: metricSlug } = metric
|
||||
items.push({
|
||||
plan: pricingPlan.stripeMetricPlans[metricSlug]!
|
||||
})
|
||||
}
|
||||
|
||||
items = items.filter((item) => item.plan)
|
||||
assert(
|
||||
items.length,
|
||||
500,
|
||||
`Error creating stripe subscription for invalid plan "${pricingPlan.slug}"`
|
||||
)
|
||||
|
||||
const createParams: Stripe.SubscriptionCreateParams = {
|
||||
customer: stripeCustomerId,
|
||||
// TODO: coupons
|
||||
// coupon: filterConsumerCoupon(ctx, consumer, deployment),
|
||||
items,
|
||||
metadata: {
|
||||
userId: consumer.userId,
|
||||
consumerId: consumer.id,
|
||||
projectId: project.id,
|
||||
deployment: deployment.id
|
||||
}
|
||||
}
|
||||
|
||||
if (pricingPlan.trialPeriodDays) {
|
||||
createParams.trial_period_days = pricingPlan.trialPeriodDays
|
||||
}
|
||||
|
||||
if (project.isStripeConnectEnabled && project.applicationFeePercent > 0) {
|
||||
createParams.application_fee_percent = project.applicationFeePercent
|
||||
}
|
||||
|
||||
console.log('subscription', action, { items })
|
||||
subscription = await stripe.subscriptions.create(
|
||||
createParams,
|
||||
...stripeConnectParams
|
||||
)
|
||||
|
||||
consumer.stripeSubscriptionId = subscription.id
|
||||
}
|
||||
|
||||
assert(subscription, 500, 'Missing stripe subscription')
|
||||
|
||||
console.log()
|
||||
console.log('subscription', JSON.stringify(subscription, null, 2))
|
||||
console.log()
|
||||
|
||||
const consumerUpdate: ConsumerUpdate = consumer
|
||||
|
||||
if (plan) {
|
||||
consumerUpdate.stripeStatus = subscription.status
|
||||
} else {
|
||||
// TODO
|
||||
consumerUpdate.stripeSubscriptionId = null
|
||||
consumerUpdate.stripeStatus = 'cancelled'
|
||||
}
|
||||
|
||||
if (pricingPlan?.stripeBasePlanId) {
|
||||
const subscriptionItem = subscription.items.data.find(
|
||||
(item) => item.plan.id === pricingPlan.stripeBasePlanId
|
||||
)
|
||||
assert(
|
||||
subscriptionItem,
|
||||
500,
|
||||
`Error initializing stripe subscription for base plan "${subscription.id}"`
|
||||
)
|
||||
|
||||
consumerUpdate.stripeSubscriptionBaseItemId = subscriptionItem.id
|
||||
assert(
|
||||
consumerUpdate.stripeSubscriptionBaseItemId,
|
||||
500,
|
||||
`Error initializing stripe subscription for base plan [${subscription.id}]`
|
||||
)
|
||||
} else {
|
||||
// TODO
|
||||
consumerUpdate.stripeSubscriptionBaseItemId = null
|
||||
}
|
||||
|
||||
if (pricingPlan?.stripeRequestPlanId) {
|
||||
const subscriptionItem = subscription.items.data.find(
|
||||
(item) => item.plan.id === pricingPlan.stripeRequestPlanId
|
||||
)
|
||||
assert(
|
||||
subscriptionItem,
|
||||
500,
|
||||
`Error initializing stripe subscription for metric "requests" on plan "${subscription.id}"`
|
||||
)
|
||||
|
||||
consumerUpdate.stripeSubscriptionRequestItemId = subscriptionItem.id
|
||||
assert(
|
||||
consumerUpdate.stripeSubscriptionRequestItemId,
|
||||
500,
|
||||
`Error initializing stripe subscription for metric "requests" on plan "${subscription.id}"`
|
||||
)
|
||||
} else {
|
||||
// TODO
|
||||
consumerUpdate.stripeSubscriptionRequestItemId = null
|
||||
}
|
||||
|
||||
const metricSlugs = (
|
||||
pricingPlan?.metrics.map((metric) => metric.slug) ?? []
|
||||
).concat(Object.keys(consumer.stripeSubscriptionMetricItems))
|
||||
|
||||
const isMetricInPricingPlan = (metricSlug: string) =>
|
||||
pricingPlan?.metrics.find((metric) => metric.slug === metricSlug)
|
||||
|
||||
for (const metricSlug of metricSlugs) {
|
||||
console.log({
|
||||
metricSlug,
|
||||
pricingPlan
|
||||
})
|
||||
const metricPlan = pricingPlan?.stripeMetricPlans[metricSlug]
|
||||
|
||||
if (metricPlan) {
|
||||
const subscriptionItem: Stripe.SubscriptionItem | undefined =
|
||||
subscription.items.data.find((item) => item.plan.id === metricPlan)
|
||||
|
||||
if (isMetricInPricingPlan(metricSlug)) {
|
||||
assert(
|
||||
subscriptionItem,
|
||||
500,
|
||||
`Error initializing stripe subscription for metric "${metricSlug}" on plan [${subscription.id}]`
|
||||
)
|
||||
|
||||
consumerUpdate.stripeSubscriptionMetricItems![metricSlug] =
|
||||
subscriptionItem.id
|
||||
assert(
|
||||
consumerUpdate.stripeSubscriptionMetricItems![metricSlug],
|
||||
500,
|
||||
`Error initializing stripe subscription for metric "${metricSlug}" on plan [${subscription.id}]`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// TODO
|
||||
consumerUpdate.stripeSubscriptionMetricItems![metricSlug] = null
|
||||
}
|
||||
}
|
||||
|
||||
console.log()
|
||||
console.log()
|
||||
console.log('consumer update', {
|
||||
...consumer,
|
||||
...consumerUpdate
|
||||
})
|
||||
console.log()
|
||||
|
||||
const [updatedConsumer] = await db
|
||||
.update(schema.consumers)
|
||||
.set(consumerUpdate as any) // TODO
|
||||
.where(eq(schema.consumers.id, consumer.id))
|
||||
.returning()
|
||||
assert(updatedConsumer, 500, 'Error updating consumer')
|
||||
|
||||
// await auditLog.createStripeSubscriptionLogEntry(ctx, {
|
||||
// consumer,
|
||||
// user,
|
||||
// plan: consumer.plan,
|
||||
// subtype: action
|
||||
// })
|
||||
|
||||
return {
|
||||
subscription,
|
||||
consumer: updatedConsumer
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import type { AuthenticatedContext } from '@/lib/types'
|
||||
import { db, eq, type RawUser, schema } from '@/db'
|
||||
|
||||
import { assert } from './utils'
|
||||
|
||||
export async function ensureAuthUser(
|
||||
ctx: AuthenticatedContext
|
||||
): Promise<RawUser> {
|
||||
let user = ctx.get('user')
|
||||
if (user) return user
|
||||
|
||||
const userId = ctx.get('userId')
|
||||
assert(userId, 401, 'Unauthorized')
|
||||
|
||||
user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.id, userId)
|
||||
})
|
||||
assert(user, 401, 'Unauthorized')
|
||||
ctx.set('user', user)
|
||||
|
||||
return user
|
||||
}
|
|
@ -31,6 +31,7 @@ export const authenticate = createMiddleware<AuthenticatedEnv>(
|
|||
401,
|
||||
'Unauthorized'
|
||||
)
|
||||
ctx.set('userId', payload.userId)
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.id, payload.userId)
|
||||
|
|
|
@ -3,5 +3,5 @@ import Stripe from 'stripe'
|
|||
import { env } from './env'
|
||||
|
||||
export const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: '2025-02-24.acacia'
|
||||
apiVersion: '2025-04-30.basil'
|
||||
})
|
||||
|
|
|
@ -1,20 +1,11 @@
|
|||
import type { Context } from 'hono'
|
||||
|
||||
import type { TeamMember, User } from '@/db'
|
||||
import type { RawTeamMember, RawUser } from '@/db'
|
||||
|
||||
export type AuthenticatedEnvVariables = {
|
||||
user: User
|
||||
teamMember?: TeamMember
|
||||
jwtPayload:
|
||||
| {
|
||||
type: 'user'
|
||||
userId: string
|
||||
username: string
|
||||
}
|
||||
| {
|
||||
type: 'project'
|
||||
projectId: string
|
||||
}
|
||||
userId: string
|
||||
user?: RawUser
|
||||
teamMember?: RawTeamMember
|
||||
}
|
||||
|
||||
export type AuthenticatedEnv = {
|
||||
|
@ -33,3 +24,13 @@ export type AuthenticatedContext = Context<AuthenticatedEnv>
|
|||
// : T extends object
|
||||
// ? { [K in keyof T]: NullToUndefinedDeep<T[K]> }
|
||||
// : T
|
||||
|
||||
export type UndefinedToNullDeep<T> = T extends undefined
|
||||
? T | null
|
||||
: T extends Date
|
||||
? T | null
|
||||
: T extends readonly (infer U)[]
|
||||
? UndefinedToNullDeep<U>[]
|
||||
: T extends object
|
||||
? { [K in keyof T]: UndefinedToNullDeep<T[K]> }
|
||||
: T | null
|
||||
|
|
|
@ -173,6 +173,9 @@ importers:
|
|||
jsonwebtoken:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.2
|
||||
p-all:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
pino:
|
||||
specifier: ^9.6.0
|
||||
version: 9.6.0
|
||||
|
@ -2855,6 +2858,10 @@ packages:
|
|||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
p-all@5.0.0:
|
||||
resolution: {integrity: sha512-pofqu/1FhCVa+78xNAptCGc9V45exFz2pvBRyIvgXkNM0Rh18Py7j8pQuSjA+zpabI46v9hRjNWmL9EAFcEbpw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
p-limit@3.1.0:
|
||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
@ -2863,6 +2870,10 @@ packages:
|
|||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-map@6.0.0:
|
||||
resolution: {integrity: sha512-T8BatKGY+k5rU+Q/GTYgrEf2r4xRMevAN5mtXc2aPc4rS1j3s+vWTaO2Wag94neXuCAUAs8cxBL9EeB5EA6diw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
p-map@7.0.3:
|
||||
resolution: {integrity: sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==}
|
||||
engines: {node: '>=18'}
|
||||
|
@ -6484,6 +6495,10 @@ snapshots:
|
|||
object-keys: 1.1.1
|
||||
safe-push-apply: 1.0.0
|
||||
|
||||
p-all@5.0.0:
|
||||
dependencies:
|
||||
p-map: 6.0.0
|
||||
|
||||
p-limit@3.1.0:
|
||||
dependencies:
|
||||
yocto-queue: 0.1.0
|
||||
|
@ -6492,6 +6507,8 @@ snapshots:
|
|||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
p-map@6.0.0: {}
|
||||
|
||||
p-map@7.0.3: {}
|
||||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
|
|
Ładowanie…
Reference in New Issue