diff --git a/apps/api/package.json b/apps/api/package.json index ba6b05e6..a6d894e8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/api-v1/consumers/admin-get-consumer-by-token.ts b/apps/api/src/api-v1/consumers/admin-get-consumer-by-token.ts new file mode 100644 index 00000000..fb4d9878 --- /dev/null +++ b/apps/api/src/api-v1/consumers/admin-get-consumer-by-token.ts @@ -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 +) { + 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)) + }) +} diff --git a/apps/api/src/api-v1/consumers/get-consumer.ts b/apps/api/src/api-v1/consumers/get-consumer.ts index 47504c06..76fefa48 100644 --- a/apps/api/src/api-v1/consumers/get-consumer.ts +++ b/apps/api/src/api-v1/consumers/get-consumer.ts @@ -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' }) diff --git a/apps/api/src/api-v1/consumers/list-consumers.ts b/apps/api/src/api-v1/consumers/list-consumers.ts new file mode 100644 index 00000000..85a75e05 --- /dev/null +++ b/apps/api/src/api-v1/consumers/list-consumers.ts @@ -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 +) { + 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) + ) + }) +} diff --git a/apps/api/src/api-v1/consumers/schemas.ts b/apps/api/src/api-v1/consumers/schemas.ts index ab4a01b5..28e01548 100644 --- a/apps/api/src/api-v1/consumers/schemas.ts +++ b/apps/api/src/api-v1/consumers/schemas.ts @@ -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 +}) diff --git a/apps/api/src/api-v1/consumers/upsert-consumer.ts b/apps/api/src/api-v1/consumers/upsert-consumer.ts new file mode 100644 index 00000000..919f871c --- /dev/null +++ b/apps/api/src/api-v1/consumers/upsert-consumer.ts @@ -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 +) { + 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)) + }) +} diff --git a/apps/api/src/api-v1/index.ts b/apps/api/src/api-v1/index.ts index 91d3c32c..e5a2c4fa 100644 --- a/apps/api/src/api-v1/index.ts +++ b/apps/api/src/api-v1/index.ts @@ -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) diff --git a/apps/api/src/api-v1/projects/create-project.ts b/apps/api/src/api-v1/projects/create-project.ts index 68c4b781..08a5bebd 100644 --- a/apps/api/src/api-v1/projects/create-project.ts +++ b/apps/api/src/api-v1/projects/create-project.ts @@ -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 }) diff --git a/apps/api/src/api-v1/projects/list-projects.ts b/apps/api/src/api-v1/projects/list-projects.ts index c95f0520..36db689a 100644 --- a/apps/api/src/api-v1/projects/list-projects.ts +++ b/apps/api/src/api-v1/projects/list-projects.ts @@ -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' diff --git a/apps/api/src/api-v1/teams/create-team.ts b/apps/api/src/api-v1/teams/create-team.ts index 1a9d389e..46aef175 100644 --- a/apps/api/src/api-v1/teams/create-team.ts +++ b/apps/api/src/api-v1/teams/create-team.ts @@ -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) { 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) diff --git a/apps/api/src/api-v1/teams/list-teams.ts b/apps/api/src/api-v1/teams/list-teams.ts index 4bd00fb8..aab10139 100644 --- a/apps/api/src/api-v1/teams/list-teams.ts +++ b/apps/api/src/api-v1/teams/list-teams.ts @@ -36,11 +36,12 @@ export function registerV1TeamsListTeams(app: OpenAPIHono) { 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 }, diff --git a/apps/api/src/db/schema/consumer.ts b/apps/api/src/db/schema/consumer.ts index 89f692b0..228fbbe6 100644 --- a/apps/api/src/db/schema/consumer.ts +++ b/apps/api/src/db/schema/consumer.ts @@ -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 = + 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() diff --git a/apps/api/src/db/schema/project.ts b/apps/api/src/db/schema/project.ts index ab14eaea..9813ad3a 100644 --- a/apps/api/src/db/schema/project.ts +++ b/apps/api/src/db/schema/project.ts @@ -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>() diff --git a/apps/api/src/db/schema/types.ts b/apps/api/src/db/schema/types.ts index 6392ef60..e6d55410 100644 --- a/apps/api/src/db/schema/types.ts +++ b/apps/api/src/db/schema/types.ts @@ -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()) }) diff --git a/apps/api/src/db/types.ts b/apps/api/src/db/types.ts index fd30550b..8c39bb5e 100644 --- a/apps/api/src/db/types.ts +++ b/apps/api/src/db/types.ts @@ -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 export type User = z.infer +export type RawUser = InferSelectModel export type Team = z.infer export type TeamWithMembers = BuildQueryResult< @@ -16,6 +21,7 @@ export type TeamWithMembers = BuildQueryResult< Tables['teams'], { with: { members: true } } > +export type RawTeam = InferSelectModel export type TeamMember = z.infer export type TeamMemberWithTeam = BuildQueryResult< @@ -23,6 +29,7 @@ export type TeamMemberWithTeam = BuildQueryResult< Tables['teamMembers'], { with: { team: true } } > +export type RawTeamMember = InferSelectModel export type Project = z.infer export type ProjectWithLastPublishedDeployment = BuildQueryResult< @@ -30,6 +37,7 @@ export type ProjectWithLastPublishedDeployment = BuildQueryResult< Tables['projects'], { with: { lastPublishedDeployment: true } } > +export type RawProject = InferSelectModel export type Deployment = z.infer export type DeploymentWithProject = BuildQueryResult< @@ -37,6 +45,7 @@ export type DeploymentWithProject = BuildQueryResult< Tables['deployments'], { with: { project: true } } > +export type RawDeployment = InferSelectModel export type Consumer = z.infer export type ConsumerWithProjectAndDeployment = BuildQueryResult< @@ -44,5 +53,15 @@ export type ConsumerWithProjectAndDeployment = BuildQueryResult< Tables['consumers'], { with: { project: true; deployment: true } } > +export type RawConsumer = InferSelectModel +export type ConsumerUpdate = Partial< + UndefinedToNullDeep< + Omit< + InferInsertModel, + 'id' | 'projectId' | 'userId' | 'deploymentId' + > + > +> export type LogEntry = z.infer +export type RawLogEntry = InferSelectModel diff --git a/apps/api/src/lib/acl-admin.ts b/apps/api/src/lib/acl-admin.ts index 01c5ea1c..f118db15 100644 --- a/apps/api/src/lib/acl-admin.ts +++ b/apps/api/src/lib/acl-admin.ts @@ -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') } diff --git a/apps/api/src/lib/acl-team-admin.ts b/apps/api/src/lib/acl-team-admin.ts index 49ec5eab..dcc602af 100644 --- a/apps/api/src/lib/acl-team-admin.ts +++ b/apps/api/src/lib/acl-team-admin.ts @@ -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 diff --git a/apps/api/src/lib/acl-team-member.ts b/apps/api/src/lib/acl-team-member.ts index 8b6e90f4..fdcc9a54 100644 --- a/apps/api/src/lib/acl-team-member.ts +++ b/apps/api/src/lib/acl-team-member.ts @@ -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') { diff --git a/apps/api/src/lib/acl.ts b/apps/api/src/lib/acl.ts index 69b90b94..4aeed5d7 100644 --- a/apps/api/src/lib/acl.ts +++ b/apps/api/src/lib/acl.ts @@ -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] diff --git a/apps/api/src/lib/billing/upsert-stripe-connect-customer.ts b/apps/api/src/lib/billing/upsert-stripe-connect-customer.ts new file mode 100644 index 00000000..ddb513c0 --- /dev/null +++ b/apps/api/src/lib/billing/upsert-stripe-connect-customer.ts @@ -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 { + 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 +} diff --git a/apps/api/src/lib/billing/upsert-stripe-customer.ts b/apps/api/src/lib/billing/upsert-stripe-customer.ts new file mode 100644 index 00000000..2429db14 --- /dev/null +++ b/apps/api/src/lib/billing/upsert-stripe-customer.ts @@ -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 + } +} diff --git a/apps/api/src/lib/billing/upsert-stripe-pricing-plans.ts b/apps/api/src/lib/billing/upsert-stripe-pricing-plans.ts new file mode 100644 index 00000000..10df2fc8 --- /dev/null +++ b/apps/api/src/lib/billing/upsert-stripe-pricing-plans.ts @@ -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 { + 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)) + ]) + } +} diff --git a/apps/api/src/lib/billing/upsert-stripe-subscription.ts b/apps/api/src/lib/billing/upsert-stripe-subscription.ts new file mode 100644 index 00000000..348ea597 --- /dev/null +++ b/apps/api/src/lib/billing/upsert-stripe-subscription.ts @@ -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 + } +} diff --git a/apps/api/src/lib/ensure-auth-user.ts b/apps/api/src/lib/ensure-auth-user.ts new file mode 100644 index 00000000..ecd31952 --- /dev/null +++ b/apps/api/src/lib/ensure-auth-user.ts @@ -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 { + 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 +} diff --git a/apps/api/src/lib/middleware/authenticate.ts b/apps/api/src/lib/middleware/authenticate.ts index 344d82a4..130171b9 100644 --- a/apps/api/src/lib/middleware/authenticate.ts +++ b/apps/api/src/lib/middleware/authenticate.ts @@ -31,6 +31,7 @@ export const authenticate = createMiddleware( 401, 'Unauthorized' ) + ctx.set('userId', payload.userId) const user = await db.query.users.findFirst({ where: eq(schema.users.id, payload.userId) diff --git a/apps/api/src/lib/stripe.ts b/apps/api/src/lib/stripe.ts index 746205c3..52fc4feb 100644 --- a/apps/api/src/lib/stripe.ts +++ b/apps/api/src/lib/stripe.ts @@ -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' }) diff --git a/apps/api/src/lib/types.ts b/apps/api/src/lib/types.ts index 743ddfd9..18bcfe0b 100644 --- a/apps/api/src/lib/types.ts +++ b/apps/api/src/lib/types.ts @@ -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 // : T extends object // ? { [K in keyof T]: NullToUndefinedDeep } // : T + +export type UndefinedToNullDeep = T extends undefined + ? T | null + : T extends Date + ? T | null + : T extends readonly (infer U)[] + ? UndefinedToNullDeep[] + : T extends object + ? { [K in keyof T]: UndefinedToNullDeep } + : T | null diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6a2aad5..b698e9c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}