kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
pull/715/head
rodzic
dd51230849
commit
f4546c2218
|
@ -52,6 +52,7 @@
|
||||||
"exit-hook": "catalog:",
|
"exit-hook": "catalog:",
|
||||||
"hono": "^4.7.7",
|
"hono": "^4.7.7",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"p-all": "^5.0.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"pino-abstract-transport": "^2.0.0",
|
"pino-abstract-transport": "^2.0.0",
|
||||||
"postgres": "^3.4.5",
|
"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 { acl } from '@/lib/acl'
|
||||||
import { assert, parseZodSchema } from '@/lib/utils'
|
import { assert, parseZodSchema } from '@/lib/utils'
|
||||||
|
|
||||||
import { consumerIdParamsSchema } from './schemas'
|
import { consumerIdParamsSchema, populateConsumerSchema } from './schemas'
|
||||||
|
|
||||||
const route = createRoute({
|
const route = createRoute({
|
||||||
description: 'Gets a consumer',
|
description: 'Gets a consumer',
|
||||||
|
@ -15,7 +15,8 @@ const route = createRoute({
|
||||||
path: 'consumers/{consumersId}',
|
path: 'consumers/{consumersId}',
|
||||||
security: [{ bearerAuth: [] }],
|
security: [{ bearerAuth: [] }],
|
||||||
request: {
|
request: {
|
||||||
params: consumerIdParamsSchema
|
params: consumerIdParamsSchema,
|
||||||
|
query: populateConsumerSchema
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
|
@ -36,9 +37,13 @@ export function registerV1ConsumersGetConsumer(
|
||||||
) {
|
) {
|
||||||
return app.openapi(route, async (c) => {
|
return app.openapi(route, async (c) => {
|
||||||
const { consumerId } = c.req.valid('param')
|
const { consumerId } = c.req.valid('param')
|
||||||
|
const { populate = [] } = c.req.valid('query')
|
||||||
|
|
||||||
const consumer = await db.query.consumers.findFirst({
|
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}"`)
|
assert(consumer, 404, `Consumer not found "${consumerId}"`)
|
||||||
await acl(c, consumer, { label: 'Consumer' })
|
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 { z } from '@hono/zod-openapi'
|
||||||
|
|
||||||
import { consumerIdSchema } from '@/db'
|
import { consumerIdSchema, paginationSchema } from '@/db'
|
||||||
|
import { consumerRelationsSchema } from '@/db/schema'
|
||||||
|
|
||||||
export const consumerIdParamsSchema = z.object({
|
export const consumerIdParamsSchema = z.object({
|
||||||
consumerId: consumerIdSchema.openapi({
|
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 type { AuthenticatedEnv } from '@/lib/types'
|
||||||
import * as middleware from '@/lib/middleware'
|
import * as middleware from '@/lib/middleware'
|
||||||
|
|
||||||
|
import { registerV1AdminConsumersGetConsumerByToken } from './consumers/admin-get-consumer-by-token'
|
||||||
import { registerV1ConsumersGetConsumer } from './consumers/get-consumer'
|
import { registerV1ConsumersGetConsumer } from './consumers/get-consumer'
|
||||||
import { registerHealthCheck } from './health-check'
|
import { registerHealthCheck } from './health-check'
|
||||||
import { registerV1ProjectsCreateProject } from './projects/create-project'
|
import { registerV1ProjectsCreateProject } from './projects/create-project'
|
||||||
|
@ -67,9 +68,12 @@ registerV1ProjectsUpdateProject(pri)
|
||||||
// Consumers crud
|
// Consumers crud
|
||||||
registerV1ConsumersGetConsumer(pri)
|
registerV1ConsumersGetConsumer(pri)
|
||||||
|
|
||||||
// webhook events
|
// Webhook event handlers
|
||||||
registerV1StripeWebhook(pub)
|
registerV1StripeWebhook(pub)
|
||||||
|
|
||||||
|
// Admin routes
|
||||||
|
registerV1AdminConsumersGetConsumerByToken(pri)
|
||||||
|
|
||||||
// Setup routes and middleware
|
// Setup routes and middleware
|
||||||
apiV1.route('/', pub)
|
apiV1.route('/', pub)
|
||||||
apiV1.use(middleware.authenticate)
|
apiV1.use(middleware.authenticate)
|
||||||
|
|
|
@ -4,6 +4,7 @@ import type { AuthenticatedEnv } from '@/lib/types'
|
||||||
import { db, schema } from '@/db'
|
import { db, schema } from '@/db'
|
||||||
import { aclTeamMember } from '@/lib/acl-team-member'
|
import { aclTeamMember } from '@/lib/acl-team-member'
|
||||||
import { getProviderToken } from '@/lib/auth/get-provider-token'
|
import { getProviderToken } from '@/lib/auth/get-provider-token'
|
||||||
|
import { ensureAuthUser } from '@/lib/ensure-auth-user'
|
||||||
import { assert, parseZodSchema, sha256 } from '@/lib/utils'
|
import { assert, parseZodSchema, sha256 } from '@/lib/utils'
|
||||||
|
|
||||||
const route = createRoute({
|
const route = createRoute({
|
||||||
|
@ -42,7 +43,7 @@ export function registerV1ProjectsCreateProject(
|
||||||
) {
|
) {
|
||||||
return app.openapi(route, async (c) => {
|
return app.openapi(route, async (c) => {
|
||||||
const body = c.req.valid('json')
|
const body = c.req.valid('json')
|
||||||
const user = c.get('user')
|
const user = await ensureAuthUser(c)
|
||||||
|
|
||||||
if (body.teamId) {
|
if (body.teamId) {
|
||||||
await aclTeamMember(c, { teamId: 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 type { AuthenticatedEnv } from '@/lib/types'
|
||||||
import { db, eq, schema } from '@/db'
|
import { db, eq, schema } from '@/db'
|
||||||
|
import { ensureAuthUser } from '@/lib/ensure-auth-user'
|
||||||
import { parseZodSchema } from '@/lib/utils'
|
import { parseZodSchema } from '@/lib/utils'
|
||||||
|
|
||||||
import { paginationAndPopulateProjectSchema } from './schemas'
|
import { paginationAndPopulateProjectSchema } from './schemas'
|
||||||
|
@ -42,7 +43,7 @@ export function registerV1ProjectsListProjects(
|
||||||
populate = []
|
populate = []
|
||||||
} = c.req.valid('query')
|
} = c.req.valid('query')
|
||||||
|
|
||||||
const user = c.get('user')
|
const user = await ensureAuthUser(c)
|
||||||
const teamMember = c.get('teamMember')
|
const teamMember = c.get('teamMember')
|
||||||
const isAdmin = user.role === 'admin'
|
const isAdmin = user.role === 'admin'
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||||
|
|
||||||
import type { AuthenticatedEnv } from '@/lib/types'
|
import type { AuthenticatedEnv } from '@/lib/types'
|
||||||
import { db, schema } from '@/db'
|
import { db, schema } from '@/db'
|
||||||
|
import { ensureAuthUser } from '@/lib/ensure-auth-user'
|
||||||
import { ensureUniqueTeamSlug } from '@/lib/ensure-unique-team-slug'
|
import { ensureUniqueTeamSlug } from '@/lib/ensure-unique-team-slug'
|
||||||
import { assert, parseZodSchema } from '@/lib/utils'
|
import { assert, parseZodSchema } from '@/lib/utils'
|
||||||
|
|
||||||
|
@ -38,7 +39,7 @@ const route = createRoute({
|
||||||
|
|
||||||
export function registerV1TeamsCreateTeam(app: OpenAPIHono<AuthenticatedEnv>) {
|
export function registerV1TeamsCreateTeam(app: OpenAPIHono<AuthenticatedEnv>) {
|
||||||
return app.openapi(route, async (c) => {
|
return app.openapi(route, async (c) => {
|
||||||
const user = c.get('user')
|
const user = await ensureAuthUser(c)
|
||||||
const body = c.req.valid('json')
|
const body = c.req.valid('json')
|
||||||
|
|
||||||
await ensureUniqueTeamSlug(body.slug)
|
await ensureUniqueTeamSlug(body.slug)
|
||||||
|
|
|
@ -36,11 +36,12 @@ export function registerV1TeamsListTeams(app: OpenAPIHono<AuthenticatedEnv>) {
|
||||||
sort = 'desc',
|
sort = 'desc',
|
||||||
sortBy = 'createdAt'
|
sortBy = 'createdAt'
|
||||||
} = c.req.valid('query')
|
} = c.req.valid('query')
|
||||||
|
const userId = c.get('userId')
|
||||||
|
|
||||||
// schema.teamMembers._.columns
|
// schema.teamMembers._.columns
|
||||||
|
|
||||||
const teamMembers = await db.query.teamMembers.findMany({
|
const teamMembers = await db.query.teamMembers.findMany({
|
||||||
where: eq(schema.teamMembers.userId, c.get('user').id),
|
where: eq(schema.teamMembers.userId, userId),
|
||||||
with: {
|
with: {
|
||||||
team: true
|
team: true
|
||||||
},
|
},
|
||||||
|
|
|
@ -67,7 +67,7 @@ export const consumers = pgTable(
|
||||||
// stripe subscription status (synced via webhooks)
|
// stripe subscription status (synced via webhooks)
|
||||||
stripeStatus: text(),
|
stripeStatus: text(),
|
||||||
|
|
||||||
stripeSubscriptionId: stripeId().notNull(),
|
stripeSubscriptionId: stripeId(),
|
||||||
stripeSubscriptionBaseItemId: stripeId(),
|
stripeSubscriptionBaseItemId: stripeId(),
|
||||||
stripeSubscriptionRequestItemId: 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({
|
.omit({
|
||||||
_stripeCustomerId: true
|
_stripeCustomerId: true
|
||||||
})
|
})
|
||||||
|
@ -131,13 +140,10 @@ export const consumerSelectSchema = createSelectSchema(consumers)
|
||||||
|
|
||||||
export const consumerInsertSchema = createInsertSchema(consumers)
|
export const consumerInsertSchema = createInsertSchema(consumers)
|
||||||
.pick({
|
.pick({
|
||||||
token: true,
|
|
||||||
plan: true,
|
plan: true,
|
||||||
env: true,
|
env: true,
|
||||||
coupon: true,
|
coupon: true,
|
||||||
source: true,
|
source: true,
|
||||||
userId: true,
|
|
||||||
projectId: true,
|
|
||||||
deploymentId: true
|
deploymentId: true
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
|
@ -66,6 +66,7 @@ export const projects = pgTable(
|
||||||
stripeBaseProductId: stripeId(),
|
stripeBaseProductId: stripeId(),
|
||||||
stripeRequestProductId: stripeId(),
|
stripeRequestProductId: stripeId(),
|
||||||
|
|
||||||
|
// Map between metric slugs and stripe product ids
|
||||||
// [metricSlug: string]: string
|
// [metricSlug: string]: string
|
||||||
stripeMetricProductIds: jsonb()
|
stripeMetricProductIds: jsonb()
|
||||||
.$type<Record<string, string>>()
|
.$type<Record<string, string>>()
|
||||||
|
|
|
@ -60,7 +60,7 @@ export const pricingPlanTierSchema = z
|
||||||
.object({
|
.object({
|
||||||
unitAmount: z.number().optional(),
|
unitAmount: z.number().optional(),
|
||||||
flatAmount: z.number().optional(),
|
flatAmount: z.number().optional(),
|
||||||
upTo: z.string()
|
upTo: z.union([z.number(), z.literal('inf')])
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) =>
|
(data) =>
|
||||||
|
@ -116,10 +116,10 @@ export const pricingPlanSchema = z
|
||||||
|
|
||||||
rateLimit: rateLimitSchema.optional(),
|
rateLimit: rateLimitSchema.optional(),
|
||||||
|
|
||||||
// used to uniquely identify this plan across deployments
|
// used to uniquely identify this pricing plan across deployments
|
||||||
baseId: z.string(),
|
baseId: z.string(),
|
||||||
|
|
||||||
// used to uniquely identify this plan across deployments
|
// used to uniquely identify this pricing plan across deployments
|
||||||
requestsId: z.string(),
|
requestsId: z.string(),
|
||||||
|
|
||||||
// [metricSlug: string]: string
|
// [metricSlug: string]: string
|
||||||
|
@ -128,9 +128,10 @@ export const pricingPlanSchema = z
|
||||||
// NOTE: the stripe billing plan id(s) for this PricingPlan are referenced
|
// NOTE: the stripe billing plan id(s) for this PricingPlan are referenced
|
||||||
// in the Project._stripePlans mapping via the plan's hash.
|
// in the Project._stripePlans mapping via the plan's hash.
|
||||||
// NOTE: all metered billing usage is stored in stripe
|
// NOTE: all metered billing usage is stored in stripe
|
||||||
stripeBasePlan: z.string(),
|
stripeBasePlanId: z.string(),
|
||||||
stripeRequestPlan: z.string(),
|
stripeRequestPlanId: z.string(),
|
||||||
|
|
||||||
|
// Record mapping metric slugs to stripe plan IDs
|
||||||
// [metricSlug: string]: string
|
// [metricSlug: string]: string
|
||||||
stripeMetricPlans: z.record(z.string())
|
stripeMetricPlans: z.record(z.string())
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,14 +1,19 @@
|
||||||
import type {
|
import type {
|
||||||
BuildQueryResult,
|
BuildQueryResult,
|
||||||
ExtractTablesWithRelations
|
ExtractTablesWithRelations,
|
||||||
|
InferInsertModel,
|
||||||
|
InferSelectModel
|
||||||
} from '@fisch0920/drizzle-orm'
|
} from '@fisch0920/drizzle-orm'
|
||||||
import type { z } from '@hono/zod-openapi'
|
import type { z } from '@hono/zod-openapi'
|
||||||
|
|
||||||
|
import type { UndefinedToNullDeep } from '@/lib/types'
|
||||||
|
|
||||||
import type * as schema from './schema'
|
import type * as schema from './schema'
|
||||||
|
|
||||||
export type Tables = ExtractTablesWithRelations<typeof schema>
|
export type Tables = ExtractTablesWithRelations<typeof schema>
|
||||||
|
|
||||||
export type User = z.infer<typeof schema.userSelectSchema>
|
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 Team = z.infer<typeof schema.teamSelectSchema>
|
||||||
export type TeamWithMembers = BuildQueryResult<
|
export type TeamWithMembers = BuildQueryResult<
|
||||||
|
@ -16,6 +21,7 @@ export type TeamWithMembers = BuildQueryResult<
|
||||||
Tables['teams'],
|
Tables['teams'],
|
||||||
{ with: { members: true } }
|
{ with: { members: true } }
|
||||||
>
|
>
|
||||||
|
export type RawTeam = InferSelectModel<typeof schema.teams>
|
||||||
|
|
||||||
export type TeamMember = z.infer<typeof schema.teamMemberSelectSchema>
|
export type TeamMember = z.infer<typeof schema.teamMemberSelectSchema>
|
||||||
export type TeamMemberWithTeam = BuildQueryResult<
|
export type TeamMemberWithTeam = BuildQueryResult<
|
||||||
|
@ -23,6 +29,7 @@ export type TeamMemberWithTeam = BuildQueryResult<
|
||||||
Tables['teamMembers'],
|
Tables['teamMembers'],
|
||||||
{ with: { team: true } }
|
{ with: { team: true } }
|
||||||
>
|
>
|
||||||
|
export type RawTeamMember = InferSelectModel<typeof schema.teamMembers>
|
||||||
|
|
||||||
export type Project = z.infer<typeof schema.projectSelectSchema>
|
export type Project = z.infer<typeof schema.projectSelectSchema>
|
||||||
export type ProjectWithLastPublishedDeployment = BuildQueryResult<
|
export type ProjectWithLastPublishedDeployment = BuildQueryResult<
|
||||||
|
@ -30,6 +37,7 @@ export type ProjectWithLastPublishedDeployment = BuildQueryResult<
|
||||||
Tables['projects'],
|
Tables['projects'],
|
||||||
{ with: { lastPublishedDeployment: true } }
|
{ with: { lastPublishedDeployment: true } }
|
||||||
>
|
>
|
||||||
|
export type RawProject = InferSelectModel<typeof schema.projects>
|
||||||
|
|
||||||
export type Deployment = z.infer<typeof schema.deploymentSelectSchema>
|
export type Deployment = z.infer<typeof schema.deploymentSelectSchema>
|
||||||
export type DeploymentWithProject = BuildQueryResult<
|
export type DeploymentWithProject = BuildQueryResult<
|
||||||
|
@ -37,6 +45,7 @@ export type DeploymentWithProject = BuildQueryResult<
|
||||||
Tables['deployments'],
|
Tables['deployments'],
|
||||||
{ with: { project: true } }
|
{ with: { project: true } }
|
||||||
>
|
>
|
||||||
|
export type RawDeployment = InferSelectModel<typeof schema.deployments>
|
||||||
|
|
||||||
export type Consumer = z.infer<typeof schema.consumerSelectSchema>
|
export type Consumer = z.infer<typeof schema.consumerSelectSchema>
|
||||||
export type ConsumerWithProjectAndDeployment = BuildQueryResult<
|
export type ConsumerWithProjectAndDeployment = BuildQueryResult<
|
||||||
|
@ -44,5 +53,15 @@ export type ConsumerWithProjectAndDeployment = BuildQueryResult<
|
||||||
Tables['consumers'],
|
Tables['consumers'],
|
||||||
{ with: { project: true; deployment: true } }
|
{ 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 LogEntry = z.infer<typeof schema.logEntrySelectSchema>
|
||||||
|
export type RawLogEntry = InferSelectModel<typeof schema.logEntries>
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import type { AuthenticatedContext } from './types'
|
import type { AuthenticatedContext } from './types'
|
||||||
|
import { ensureAuthUser } from './ensure-auth-user'
|
||||||
import { assert } from './utils'
|
import { assert } from './utils'
|
||||||
|
|
||||||
export async function aclAdmin(ctx: AuthenticatedContext) {
|
export async function aclAdmin(ctx: AuthenticatedContext) {
|
||||||
const user = ctx.get('user')
|
const user = await ensureAuthUser(ctx)
|
||||||
assert(user, 401, 'Authentication required')
|
assert(user, 401, 'Authentication required')
|
||||||
assert(user.role === 'admin', 403, 'Access denied')
|
assert(user.role === 'admin', 403, 'Access denied')
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { and, db, eq, schema, type TeamMember } from '@/db'
|
import { and, db, eq, schema, type TeamMember } from '@/db'
|
||||||
|
|
||||||
import type { AuthenticatedContext } from './types'
|
import type { AuthenticatedContext } from './types'
|
||||||
|
import { ensureAuthUser } from './ensure-auth-user'
|
||||||
import { assert } from './utils'
|
import { assert } from './utils'
|
||||||
|
|
||||||
export async function aclTeamAdmin(
|
export async function aclTeamAdmin(
|
||||||
|
@ -13,8 +14,7 @@ export async function aclTeamAdmin(
|
||||||
teamMember?: TeamMember
|
teamMember?: TeamMember
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const user = ctx.get('user')
|
const user = await ensureAuthUser(ctx)
|
||||||
assert(user, 401, 'Authentication required')
|
|
||||||
|
|
||||||
if (user.role === 'admin') {
|
if (user.role === 'admin') {
|
||||||
// TODO: Allow admins to access all team resources
|
// 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 type { AuthenticatedContext } from './types'
|
||||||
|
import { ensureAuthUser } from './ensure-auth-user'
|
||||||
import { assert } from './utils'
|
import { assert } from './utils'
|
||||||
|
|
||||||
export async function aclTeamMember(
|
export async function aclTeamMember(
|
||||||
|
@ -13,12 +14,15 @@ export async function aclTeamMember(
|
||||||
}: {
|
}: {
|
||||||
teamSlug?: string
|
teamSlug?: string
|
||||||
teamId?: string
|
teamId?: string
|
||||||
teamMember?: TeamMember
|
teamMember?: RawTeamMember
|
||||||
userId?: string
|
userId?: string
|
||||||
} & ({ teamSlug: string } | { teamId: string } | { teamMember: TeamMember })
|
} & (
|
||||||
|
| { teamSlug: string }
|
||||||
|
| { teamId: string }
|
||||||
|
| { teamMember: RawTeamMember }
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
const user = ctx.get('user')
|
const user = await ensureAuthUser(ctx)
|
||||||
assert(user, 401, 'Authentication required')
|
|
||||||
assert(teamSlug || teamId, 500, 'Either teamSlug or teamId must be provided')
|
assert(teamSlug || teamId, 500, 'Either teamSlug or teamId must be provided')
|
||||||
|
|
||||||
if (user.role === 'admin') {
|
if (user.role === 'admin') {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { AuthenticatedContext } from './types'
|
import type { AuthenticatedContext } from './types'
|
||||||
|
import { ensureAuthUser } from './ensure-auth-user'
|
||||||
import { assert } from './utils'
|
import { assert } from './utils'
|
||||||
|
|
||||||
export async function acl<
|
export async function acl<
|
||||||
|
@ -18,9 +19,7 @@ export async function acl<
|
||||||
teamField?: TTeamField
|
teamField?: TTeamField
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const user = ctx.get('user')
|
const user = await ensureAuthUser(ctx)
|
||||||
assert(user, 401, 'Authentication required')
|
|
||||||
|
|
||||||
const teamMember = ctx.get('teamMember')
|
const teamMember = ctx.get('teamMember')
|
||||||
|
|
||||||
const userFieldValue = model[userField]
|
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,
|
401,
|
||||||
'Unauthorized'
|
'Unauthorized'
|
||||||
)
|
)
|
||||||
|
ctx.set('userId', payload.userId)
|
||||||
|
|
||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: eq(schema.users.id, payload.userId)
|
where: eq(schema.users.id, payload.userId)
|
||||||
|
|
|
@ -3,5 +3,5 @@ import Stripe from 'stripe'
|
||||||
import { env } from './env'
|
import { env } from './env'
|
||||||
|
|
||||||
export const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
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 { Context } from 'hono'
|
||||||
|
|
||||||
import type { TeamMember, User } from '@/db'
|
import type { RawTeamMember, RawUser } from '@/db'
|
||||||
|
|
||||||
export type AuthenticatedEnvVariables = {
|
export type AuthenticatedEnvVariables = {
|
||||||
user: User
|
userId: string
|
||||||
teamMember?: TeamMember
|
user?: RawUser
|
||||||
jwtPayload:
|
teamMember?: RawTeamMember
|
||||||
| {
|
|
||||||
type: 'user'
|
|
||||||
userId: string
|
|
||||||
username: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'project'
|
|
||||||
projectId: string
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AuthenticatedEnv = {
|
export type AuthenticatedEnv = {
|
||||||
|
@ -33,3 +24,13 @@ export type AuthenticatedContext = Context<AuthenticatedEnv>
|
||||||
// : T extends object
|
// : T extends object
|
||||||
// ? { [K in keyof T]: NullToUndefinedDeep<T[K]> }
|
// ? { [K in keyof T]: NullToUndefinedDeep<T[K]> }
|
||||||
// : T
|
// : 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:
|
jsonwebtoken:
|
||||||
specifier: ^9.0.2
|
specifier: ^9.0.2
|
||||||
version: 9.0.2
|
version: 9.0.2
|
||||||
|
p-all:
|
||||||
|
specifier: ^5.0.0
|
||||||
|
version: 5.0.0
|
||||||
pino:
|
pino:
|
||||||
specifier: ^9.6.0
|
specifier: ^9.6.0
|
||||||
version: 9.6.0
|
version: 9.6.0
|
||||||
|
@ -2855,6 +2858,10 @@ packages:
|
||||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
p-all@5.0.0:
|
||||||
|
resolution: {integrity: sha512-pofqu/1FhCVa+78xNAptCGc9V45exFz2pvBRyIvgXkNM0Rh18Py7j8pQuSjA+zpabI46v9hRjNWmL9EAFcEbpw==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
p-limit@3.1.0:
|
p-limit@3.1.0:
|
||||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
@ -2863,6 +2870,10 @@ packages:
|
||||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
p-map@6.0.0:
|
||||||
|
resolution: {integrity: sha512-T8BatKGY+k5rU+Q/GTYgrEf2r4xRMevAN5mtXc2aPc4rS1j3s+vWTaO2Wag94neXuCAUAs8cxBL9EeB5EA6diw==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
p-map@7.0.3:
|
p-map@7.0.3:
|
||||||
resolution: {integrity: sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==}
|
resolution: {integrity: sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
@ -6484,6 +6495,10 @@ snapshots:
|
||||||
object-keys: 1.1.1
|
object-keys: 1.1.1
|
||||||
safe-push-apply: 1.0.0
|
safe-push-apply: 1.0.0
|
||||||
|
|
||||||
|
p-all@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
p-map: 6.0.0
|
||||||
|
|
||||||
p-limit@3.1.0:
|
p-limit@3.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
yocto-queue: 0.1.0
|
yocto-queue: 0.1.0
|
||||||
|
@ -6492,6 +6507,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-limit: 3.1.0
|
p-limit: 3.1.0
|
||||||
|
|
||||||
|
p-map@6.0.0: {}
|
||||||
|
|
||||||
p-map@7.0.3: {}
|
p-map@7.0.3: {}
|
||||||
|
|
||||||
package-json-from-dist@1.0.1: {}
|
package-json-from-dist@1.0.1: {}
|
||||||
|
|
Ładowanie…
Reference in New Issue