pull/715/head
Travis Fischer 2025-05-05 22:45:01 +07:00
rodzic dd51230849
commit f4546c2218
28 zmienionych plików z 1173 dodań i 45 usunięć

Wyświetl plik

@ -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",

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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' })

Wyświetl plik

@ -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)
)
})
}

Wyświetl plik

@ -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
})

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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)

Wyświetl plik

@ -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 })

Wyświetl plik

@ -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'

Wyświetl plik

@ -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)

Wyświetl plik

@ -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
},

Wyświetl plik

@ -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()

Wyświetl plik

@ -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>>()

Wyświetl plik

@ -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())
})

Wyświetl plik

@ -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>

Wyświetl plik

@ -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')
}

Wyświetl plik

@ -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

Wyświetl plik

@ -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') {

Wyświetl plik

@ -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]

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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
}
}

Wyświetl plik

@ -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))
])
}
}

Wyświetl plik

@ -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
}
}

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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)

Wyświetl plik

@ -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'
})

Wyświetl plik

@ -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

Wyświetl plik

@ -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: {}