diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index 2eda2d9ef9..612f51d2a7 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -27,6 +27,7 @@ import { modelHistory, } from "@llmgateway/db"; import { models, providers } from "@llmgateway/models"; +import { DEV_PLAN_PRICES } from "@llmgateway/shared"; import { getResendClient, fromEmail, @@ -6928,4 +6929,890 @@ admin.openapi(getPaymentFailures, async (c) => { }); }); +// ============================================================================= +// DevPass admin routes +// ============================================================================= + +const devpassTierSchema = z.enum(["lite", "pro", "max", "none"]); +const devpassStatusSchema = z.enum([ + "active", + "cancelled_pending", + "expired", + "churned", +]); + +const devpassSubscriberSchema = z.object({ + id: z.string(), + name: z.string(), + billingEmail: z.string(), + ownerUserId: z.string().nullable(), + ownerName: z.string().nullable(), + ownerEmail: z.string().nullable(), + tier: devpassTierSchema, + status: devpassStatusSchema, + hasPaymentIssue: z.boolean(), + creditsUsed: z.string(), + creditsLimit: z.string(), + utilizationPct: z.number().nullable(), + cycleStart: z.string().nullable(), + cycleDaysIn: z.number().nullable(), + expiresAt: z.string().nullable(), + cancelled: z.boolean(), + allowAllModels: z.boolean(), + mrr: z.number(), + realCost: z.number(), + margin: z.number(), + subscribedSince: z.string().nullable(), + tierChanges: z.number(), + lastPaymentFailureAt: z.string().nullable(), + createdAt: z.string(), +}); + +const devpassKpisSchema = z.object({ + activeByTier: z.object({ + lite: z.number(), + pro: z.number(), + max: z.number(), + }), + totalActive: z.number(), + cancelledPending: z.number(), + churned: z.number(), + grossMrr: z.number(), + startsThisMonth: z.number(), + endsThisMonth: z.number(), + netNewThisMonth: z.number(), + weightedAvgUtilization: z.number(), + totalRealCostCycle: z.number(), + totalMrrCycle: z.number(), + totalMargin: z.number(), +}); + +const devpassListSchema = z.object({ + subscribers: z.array(devpassSubscriberSchema), + total: z.number(), + kpis: devpassKpisSchema, + limit: z.number(), + offset: z.number(), +}); + +const devpassSortBySchema = z.enum([ + "name", + "billingEmail", + "tier", + "createdAt", + "cycleStart", + "expiresAt", + "subscribedSince", + "utilizationPct", + "realCost", + "margin", + "mrr", + "creditsUsed", +]); + +const devpassUtilizationSchema = z.enum(["low", "healthy", "high", "over"]); + +const getDevpassSubscribers = createRoute({ + method: "get", + path: "/devpass", + request: { + query: z.object({ + limit: z.coerce.number().min(1).max(100).default(50).optional(), + offset: z.coerce.number().min(0).default(0).optional(), + search: z.string().optional(), + tier: devpassTierSchema.optional(), + status: devpassStatusSchema.optional(), + utilization: devpassUtilizationSchema.optional(), + marginNegative: z.coerce.boolean().optional(), + showChurned: z.coerce.boolean().default(false).optional(), + sortBy: devpassSortBySchema.default("subscribedSince").optional(), + sortOrder: sortOrderSchema.default("desc").optional(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: devpassListSchema.openapi({}), + }, + }, + description: "List of DevPass subscribers.", + }, + }, +}); + +const devpassTransactionSchema = z.object({ + id: z.string(), + createdAt: z.string(), + type: z.string(), + amount: z.string().nullable(), + creditAmount: z.string().nullable(), + currency: z.string(), + status: z.string(), + description: z.string().nullable(), +}); + +const devpassPaymentFailureSchema = z.object({ + id: z.string(), + createdAt: z.string(), + amount: z.string().nullable(), + currency: z.string(), + declineCode: z.string().nullable(), + failureMessage: z.string().nullable(), + source: z.string().nullable(), +}); + +const devpassDetailSchema = z.object({ + subscriber: devpassSubscriberSchema, + transactions: z.array(devpassTransactionSchema), + paymentFailures: z.array(devpassPaymentFailureSchema), +}); + +const getDevpassSubscriber = createRoute({ + method: "get", + path: "/devpass/{orgId}", + request: { + params: z.object({ + orgId: z.string(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: devpassDetailSchema.openapi({}), + }, + }, + description: "DevPass subscriber detail.", + }, + 404: { + description: "Subscriber not found.", + }, + }, +}); + +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; + +function tierPriceOf(tier: string): number { + if (tier === "lite" || tier === "pro" || tier === "max") { + return DEV_PLAN_PRICES[tier]; + } + return 0; +} + +function deriveStatus( + tier: string, + cancelled: boolean, + expiresAt: Date | null, + now: Date, +): "active" | "cancelled_pending" | "expired" | "churned" { + if (tier === "none") { + return "churned"; + } + if (expiresAt && expiresAt.getTime() <= now.getTime()) { + return "expired"; + } + if (cancelled) { + return "cancelled_pending"; + } + return "active"; +} + +admin.openapi(getDevpassSubscribers, async (c) => { + const query = c.req.valid("query"); + const limit = query.limit ?? 50; + const offset = query.offset ?? 0; + const search = query.search; + const tierFilter = query.tier; + const statusFilter = query.status; + const utilizationFilter = query.utilization; + const marginNegative = query.marginNegative ?? false; + const showChurned = query.showChurned ?? false; + const sortBy = query.sortBy ?? "subscribedSince"; + const sortOrder = query.sortOrder ?? "desc"; + + const now = new Date(); + const monthStart = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1), + ); + + // Subquery: real provider cost in current cycle, per org + const realCostSub = db + .select({ + organizationId: tables.project.organizationId, + realCost: + sql`COALESCE(SUM(CAST(${projectHourlyStats.cost} AS NUMERIC)), 0)`.as( + "real_cost", + ), + }) + .from(projectHourlyStats) + .innerJoin( + tables.project, + eq(projectHourlyStats.projectId, tables.project.id), + ) + .innerJoin( + tables.organization, + and( + eq(tables.project.organizationId, tables.organization.id), + isNotNull(tables.organization.devPlanBillingCycleStart), + sql`${projectHourlyStats.hourTimestamp} >= ${tables.organization.devPlanBillingCycleStart}`, + ), + ) + .groupBy(tables.project.organizationId) + .as("real_cost_sub"); + + // Subquery: first dev_plan_start (subscribedSince) and tier change count + const subscribedSinceSub = db + .select({ + organizationId: tables.transaction.organizationId, + firstStart: sql`MIN(${tables.transaction.createdAt})`.as( + "first_start", + ), + }) + .from(tables.transaction) + .where(eq(tables.transaction.type, "dev_plan_start")) + .groupBy(tables.transaction.organizationId) + .as("subscribed_since_sub"); + + const tierChangesSub = db + .select({ + organizationId: tables.transaction.organizationId, + count: sql`COUNT(*)`.as("tier_change_count"), + }) + .from(tables.transaction) + .where( + inArray(tables.transaction.type, [ + "dev_plan_upgrade", + "dev_plan_downgrade", + ]), + ) + .groupBy(tables.transaction.organizationId) + .as("tier_changes_sub"); + + const lastPaymentFailureSub = db + .select({ + organizationId: tables.paymentFailure.organizationId, + lastFailureAt: sql`MAX(${tables.paymentFailure.createdAt})`.as( + "last_failure_at", + ), + }) + .from(tables.paymentFailure) + .groupBy(tables.paymentFailure.organizationId) + .as("last_payment_failure_sub"); + + const ownerSub = db + .select({ + organizationId: tables.userOrganization.organizationId, + userId: tables.user.id, + userName: tables.user.name, + userEmail: tables.user.email, + }) + .from(tables.userOrganization) + .innerJoin(tables.user, eq(tables.userOrganization.userId, tables.user.id)) + .where(eq(tables.userOrganization.role, "owner")) + .as("owner_sub"); + + const tierPriceExpr = sql`CASE + WHEN ${tables.organization.devPlan} = 'lite' THEN ${DEV_PLAN_PRICES.lite} + WHEN ${tables.organization.devPlan} = 'pro' THEN ${DEV_PLAN_PRICES.pro} + WHEN ${tables.organization.devPlan} = 'max' THEN ${DEV_PLAN_PRICES.max} + ELSE 0 + END`; + + const utilizationExpr = sql`CASE + WHEN CAST(${tables.organization.devPlanCreditsLimit} AS NUMERIC) > 0 + THEN (CAST(${tables.organization.devPlanCreditsUsed} AS NUMERIC) + / CAST(${tables.organization.devPlanCreditsLimit} AS NUMERIC)) * 100 + ELSE NULL + END`; + + const realCostExpr = sql`COALESCE(CAST(${realCostSub.realCost} AS NUMERIC), 0)`; + const marginExpr = sql`(${tierPriceExpr}) - COALESCE(CAST(${realCostSub.realCost} AS NUMERIC), 0)`; + + const conditions = []; + + // DevPass scope: subscribers (devPlan != 'none') OR (showChurned && has past dev_plan_start) + if (showChurned) { + conditions.push( + or( + ne(tables.organization.devPlan, "none"), + isNotNull(subscribedSinceSub.firstStart), + )!, + ); + } else { + conditions.push(ne(tables.organization.devPlan, "none")); + } + + if (tierFilter) { + conditions.push(eq(tables.organization.devPlan, tierFilter)); + } + + if (statusFilter === "active") { + conditions.push(ne(tables.organization.devPlan, "none")); + conditions.push(eq(tables.organization.devPlanCancelled, false)); + conditions.push( + or( + isNull(tables.organization.devPlanExpiresAt), + sql`${tables.organization.devPlanExpiresAt} > NOW()`, + )!, + ); + } else if (statusFilter === "cancelled_pending") { + conditions.push(ne(tables.organization.devPlan, "none")); + conditions.push(eq(tables.organization.devPlanCancelled, true)); + conditions.push( + or( + isNull(tables.organization.devPlanExpiresAt), + sql`${tables.organization.devPlanExpiresAt} > NOW()`, + )!, + ); + } else if (statusFilter === "expired") { + conditions.push(ne(tables.organization.devPlan, "none")); + conditions.push(isNotNull(tables.organization.devPlanExpiresAt)); + conditions.push(sql`${tables.organization.devPlanExpiresAt} <= NOW()`); + } else if (statusFilter === "churned") { + conditions.push(eq(tables.organization.devPlan, "none")); + conditions.push(isNotNull(subscribedSinceSub.firstStart)); + } + + if (utilizationFilter === "low") { + conditions.push(sql`${utilizationExpr} < 20`); + } else if (utilizationFilter === "healthy") { + conditions.push(sql`${utilizationExpr} >= 20 AND ${utilizationExpr} <= 80`); + } else if (utilizationFilter === "high") { + conditions.push(sql`${utilizationExpr} > 80 AND ${utilizationExpr} <= 100`); + } else if (utilizationFilter === "over") { + conditions.push(sql`${utilizationExpr} > 100`); + } + + if (marginNegative) { + conditions.push(sql`${marginExpr} < 0`); + } + + if (search) { + const searchLower = search.toLowerCase(); + conditions.push( + or( + sql`LOWER(${tables.organization.name}) LIKE ${`%${searchLower}%`}`, + sql`LOWER(${tables.organization.billingEmail}) LIKE ${`%${searchLower}%`}`, + sql`${tables.organization.id} LIKE ${`%${search}%`}`, + sql`LOWER(${ownerSub.userEmail}) LIKE ${`%${searchLower}%`}`, + )!, + ); + } + + const whereClause = and(...conditions); + + const orderFn = sortOrder === "asc" ? asc : desc; + const sortColumnMap = { + name: tables.organization.name, + billingEmail: tables.organization.billingEmail, + tier: tables.organization.devPlan, + createdAt: tables.organization.createdAt, + cycleStart: tables.organization.devPlanBillingCycleStart, + expiresAt: tables.organization.devPlanExpiresAt, + subscribedSince: sql`${subscribedSinceSub.firstStart}`, + utilizationPct: sql`${utilizationExpr}`, + realCost: sql`${realCostExpr}`, + margin: sql`${marginExpr}`, + mrr: sql`${tierPriceExpr}`, + creditsUsed: sql`CAST(${tables.organization.devPlanCreditsUsed} AS NUMERIC)`, + } as const; + const sortColumn = sortColumnMap[sortBy]; + + const baseSelect = db + .select({ + id: tables.organization.id, + name: tables.organization.name, + billingEmail: tables.organization.billingEmail, + tier: tables.organization.devPlan, + creditsUsed: tables.organization.devPlanCreditsUsed, + creditsLimit: tables.organization.devPlanCreditsLimit, + cycleStart: tables.organization.devPlanBillingCycleStart, + expiresAt: tables.organization.devPlanExpiresAt, + cancelled: tables.organization.devPlanCancelled, + allowAllModels: tables.organization.devPlanAllowAllModels, + createdAt: tables.organization.createdAt, + paymentFailureCount: tables.organization.paymentFailureCount, + utilizationPct: utilizationExpr, + mrr: tierPriceExpr, + realCost: realCostExpr, + margin: marginExpr, + subscribedSince: subscribedSinceSub.firstStart, + tierChanges: sql`COALESCE(${tierChangesSub.count}, 0)`, + lastPaymentFailureAt: lastPaymentFailureSub.lastFailureAt, + ownerUserId: ownerSub.userId, + ownerName: ownerSub.userName, + ownerEmail: ownerSub.userEmail, + }) + .from(tables.organization) + .leftJoin( + realCostSub, + eq(tables.organization.id, realCostSub.organizationId), + ) + .leftJoin( + subscribedSinceSub, + eq(tables.organization.id, subscribedSinceSub.organizationId), + ) + .leftJoin( + tierChangesSub, + eq(tables.organization.id, tierChangesSub.organizationId), + ) + .leftJoin( + lastPaymentFailureSub, + eq(tables.organization.id, lastPaymentFailureSub.organizationId), + ) + .leftJoin(ownerSub, eq(tables.organization.id, ownerSub.organizationId)); + + const rows = await baseSelect + .where(whereClause) + .orderBy(orderFn(sortColumn)) + .limit(limit) + .offset(offset); + + // Total count with same filters + const countSelect = db + .select({ count: sql`COUNT(*)` }) + .from(tables.organization) + .leftJoin( + realCostSub, + eq(tables.organization.id, realCostSub.organizationId), + ) + .leftJoin( + subscribedSinceSub, + eq(tables.organization.id, subscribedSinceSub.organizationId), + ) + .leftJoin( + tierChangesSub, + eq(tables.organization.id, tierChangesSub.organizationId), + ) + .leftJoin( + lastPaymentFailureSub, + eq(tables.organization.id, lastPaymentFailureSub.organizationId), + ) + .leftJoin(ownerSub, eq(tables.organization.id, ownerSub.organizationId)); + + const [countRow] = await countSelect.where(whereClause); + const total = Number(countRow?.count ?? 0); + + // KPI strip — always over the full active subscriber base, unfiltered + const activeRows = await db + .select({ + tier: tables.organization.devPlan, + count: sql`COUNT(*)`, + }) + .from(tables.organization) + .where( + and( + ne(tables.organization.devPlan, "none"), + eq(tables.organization.devPlanCancelled, false), + or( + isNull(tables.organization.devPlanExpiresAt), + sql`${tables.organization.devPlanExpiresAt} > NOW()`, + )!, + ), + ) + .groupBy(tables.organization.devPlan); + + const activeByTier = { lite: 0, pro: 0, max: 0 }; + for (const r of activeRows) { + const tierKey = r.tier as keyof typeof activeByTier; + if (tierKey in activeByTier) { + activeByTier[tierKey] = Number(r.count); + } + } + const totalActive = activeByTier.lite + activeByTier.pro + activeByTier.max; + const liteMrr = activeByTier.lite * DEV_PLAN_PRICES.lite; + const proMrr = activeByTier.pro * DEV_PLAN_PRICES.pro; + const maxMrr = activeByTier.max * DEV_PLAN_PRICES.max; + const grossMrr = liteMrr + proMrr + maxMrr; + + const [cancelledPendingRow] = await db + .select({ count: sql`COUNT(*)` }) + .from(tables.organization) + .where( + and( + ne(tables.organization.devPlan, "none"), + eq(tables.organization.devPlanCancelled, true), + or( + isNull(tables.organization.devPlanExpiresAt), + sql`${tables.organization.devPlanExpiresAt} > NOW()`, + )!, + ), + ); + const cancelledPending = Number(cancelledPendingRow?.count ?? 0); + + const [churnedRow] = await db + .select({ + count: sql`COUNT(DISTINCT ${tables.transaction.organizationId})`, + }) + .from(tables.transaction) + .innerJoin( + tables.organization, + eq(tables.transaction.organizationId, tables.organization.id), + ) + .where( + and( + eq(tables.transaction.type, "dev_plan_start"), + eq(tables.organization.devPlan, "none"), + ), + ); + const churned = Number(churnedRow?.count ?? 0); + + const [startsRow] = await db + .select({ count: sql`COUNT(*)` }) + .from(tables.transaction) + .where( + and( + eq(tables.transaction.type, "dev_plan_start"), + gte(tables.transaction.createdAt, monthStart), + ), + ); + const startsThisMonth = Number(startsRow?.count ?? 0); + + const [endsRow] = await db + .select({ count: sql`COUNT(*)` }) + .from(tables.transaction) + .where( + and( + inArray(tables.transaction.type, ["dev_plan_cancel", "dev_plan_end"]), + gte(tables.transaction.createdAt, monthStart), + ), + ); + const endsThisMonth = Number(endsRow?.count ?? 0); + + // Weighted utilization across active subscribers + const [utilRow] = await db + .select({ + totalUsed: sql`COALESCE(SUM(CAST(${tables.organization.devPlanCreditsUsed} AS NUMERIC)), 0)`, + totalLimit: sql`COALESCE(SUM(CAST(${tables.organization.devPlanCreditsLimit} AS NUMERIC)), 0)`, + }) + .from(tables.organization) + .where( + and( + ne(tables.organization.devPlan, "none"), + or( + isNull(tables.organization.devPlanExpiresAt), + sql`${tables.organization.devPlanExpiresAt} > NOW()`, + )!, + ), + ); + const totalUsed = Number(utilRow?.totalUsed ?? 0); + const totalLimit = Number(utilRow?.totalLimit ?? 0); + const weightedAvgUtilization = + totalLimit > 0 ? (totalUsed / totalLimit) * 100 : 0; + + // Cycle-windowed totals across the active subscriber universe (not paginated) + const [universeRow] = await db + .select({ + totalCost: sql`COALESCE(SUM(CAST(${realCostSub.realCost} AS NUMERIC)), 0)`, + totalMrr: sql`COALESCE(SUM(${tierPriceExpr}), 0)`, + }) + .from(tables.organization) + .leftJoin( + realCostSub, + eq(tables.organization.id, realCostSub.organizationId), + ) + .where(ne(tables.organization.devPlan, "none")); + const totalRealCostCycle = Number(universeRow?.totalCost ?? 0); + const totalMrrCycle = Number(universeRow?.totalMrr ?? 0); + const totalMargin = totalMrrCycle - totalRealCostCycle; + + const subscribers = rows.map((row) => { + const tier = row.tier; + const cancelled = row.cancelled; + const expiresAt = row.expiresAt; + const status = deriveStatus(tier, cancelled, expiresAt, now); + + const cycleStart = row.cycleStart; + const cycleDaysIn = cycleStart + ? Math.max( + 0, + Math.floor( + (now.getTime() - cycleStart.getTime()) / (1000 * 60 * 60 * 24), + ), + ) + : null; + + const utilizationPctRaw = row.utilizationPct; + const utilizationPct = + utilizationPctRaw === null || utilizationPctRaw === undefined + ? null + : Number(utilizationPctRaw); + + const lastPaymentFailureAt = row.lastPaymentFailureAt + ? new Date(row.lastPaymentFailureAt).toISOString() + : null; + const hasPaymentIssue = + (row.paymentFailureCount ?? 0) > 0 || + (lastPaymentFailureAt !== null && + new Date(lastPaymentFailureAt).getTime() > + now.getTime() - THIRTY_DAYS_MS); + + return { + id: row.id, + name: row.name, + billingEmail: row.billingEmail, + ownerUserId: row.ownerUserId ?? null, + ownerName: row.ownerName ?? null, + ownerEmail: row.ownerEmail ?? null, + tier, + status, + hasPaymentIssue, + creditsUsed: String(row.creditsUsed), + creditsLimit: String(row.creditsLimit), + utilizationPct, + cycleStart: cycleStart ? cycleStart.toISOString() : null, + cycleDaysIn, + expiresAt: expiresAt ? expiresAt.toISOString() : null, + cancelled, + allowAllModels: row.allowAllModels, + mrr: Number(row.mrr ?? 0), + realCost: Number(row.realCost ?? 0), + margin: Number(row.margin ?? 0), + subscribedSince: row.subscribedSince + ? new Date(row.subscribedSince).toISOString() + : null, + tierChanges: Number(row.tierChanges ?? 0), + lastPaymentFailureAt, + createdAt: row.createdAt.toISOString(), + }; + }); + + return c.json({ + subscribers, + total, + kpis: { + activeByTier, + totalActive, + cancelledPending, + churned, + grossMrr, + startsThisMonth, + endsThisMonth, + netNewThisMonth: startsThisMonth - endsThisMonth, + weightedAvgUtilization, + totalRealCostCycle, + totalMrrCycle, + totalMargin, + }, + limit, + offset, + }); +}); + +admin.openapi(getDevpassSubscriber, async (c) => { + const { orgId } = c.req.valid("param"); + const now = new Date(); + + const org = await db.query.organization.findFirst({ + where: { id: { eq: orgId } }, + }); + + if (!org) { + throw new HTTPException(404, { message: "Subscriber not found" }); + } + + const owner = await db + .select({ + userId: tables.user.id, + userName: tables.user.name, + userEmail: tables.user.email, + }) + .from(tables.userOrganization) + .innerJoin(tables.user, eq(tables.userOrganization.userId, tables.user.id)) + .where( + and( + eq(tables.userOrganization.organizationId, orgId), + eq(tables.userOrganization.role, "owner"), + ), + ) + .limit(1); + + const [firstStartRow] = await db + .select({ + firstStart: sql`MIN(${tables.transaction.createdAt})`, + }) + .from(tables.transaction) + .where( + and( + eq(tables.transaction.organizationId, orgId), + eq(tables.transaction.type, "dev_plan_start"), + ), + ); + + if (org.devPlan === "none" && !firstStartRow?.firstStart) { + throw new HTTPException(404, { message: "Subscriber not found" }); + } + + const [tierChangesRow] = await db + .select({ + count: sql`COUNT(*)`, + }) + .from(tables.transaction) + .where( + and( + eq(tables.transaction.organizationId, orgId), + inArray(tables.transaction.type, [ + "dev_plan_upgrade", + "dev_plan_downgrade", + ]), + ), + ); + + const [realCostRow] = org.devPlanBillingCycleStart + ? await db + .select({ + total: sql`COALESCE(SUM(CAST(${projectHourlyStats.cost} AS NUMERIC)), 0)`, + }) + .from(projectHourlyStats) + .innerJoin( + tables.project, + eq(projectHourlyStats.projectId, tables.project.id), + ) + .where( + and( + eq(tables.project.organizationId, orgId), + gte(projectHourlyStats.hourTimestamp, org.devPlanBillingCycleStart), + ), + ) + : [{ total: "0" }]; + + const realCost = Number(realCostRow?.total ?? 0); + const mrr = tierPriceOf(org.devPlan); + const margin = mrr - realCost; + + const status = deriveStatus( + org.devPlan, + org.devPlanCancelled, + org.devPlanExpiresAt, + now, + ); + const utilizationPct = + Number(org.devPlanCreditsLimit) > 0 + ? (Number(org.devPlanCreditsUsed) / Number(org.devPlanCreditsLimit)) * 100 + : null; + const cycleDaysIn = org.devPlanBillingCycleStart + ? Math.max( + 0, + Math.floor( + (now.getTime() - org.devPlanBillingCycleStart.getTime()) / + (1000 * 60 * 60 * 24), + ), + ) + : null; + + const [lastFailureRow] = await db + .select({ + lastFailureAt: sql`MAX(${tables.paymentFailure.createdAt})`, + }) + .from(tables.paymentFailure) + .where(eq(tables.paymentFailure.organizationId, orgId)); + + const hasPaymentIssue = + (org.paymentFailureCount ?? 0) > 0 || + (lastFailureRow?.lastFailureAt !== undefined && + lastFailureRow.lastFailureAt !== null && + new Date(lastFailureRow.lastFailureAt).getTime() > + now.getTime() - THIRTY_DAYS_MS); + + const subscriber = { + id: org.id, + name: org.name, + billingEmail: org.billingEmail, + ownerUserId: owner[0]?.userId ?? null, + ownerName: owner[0]?.userName ?? null, + ownerEmail: owner[0]?.userEmail ?? null, + tier: org.devPlan, + status, + hasPaymentIssue, + creditsUsed: String(org.devPlanCreditsUsed), + creditsLimit: String(org.devPlanCreditsLimit), + utilizationPct, + cycleStart: org.devPlanBillingCycleStart + ? org.devPlanBillingCycleStart.toISOString() + : null, + cycleDaysIn, + expiresAt: org.devPlanExpiresAt ? org.devPlanExpiresAt.toISOString() : null, + cancelled: org.devPlanCancelled, + allowAllModels: org.devPlanAllowAllModels, + mrr, + realCost, + margin, + subscribedSince: firstStartRow?.firstStart + ? new Date(firstStartRow.firstStart).toISOString() + : null, + tierChanges: Number(tierChangesRow?.count ?? 0), + lastPaymentFailureAt: lastFailureRow?.lastFailureAt + ? new Date(lastFailureRow.lastFailureAt).toISOString() + : null, + createdAt: org.createdAt.toISOString(), + }; + + const transactions = await db + .select({ + id: tables.transaction.id, + createdAt: tables.transaction.createdAt, + type: tables.transaction.type, + amount: tables.transaction.amount, + creditAmount: tables.transaction.creditAmount, + currency: tables.transaction.currency, + status: tables.transaction.status, + description: tables.transaction.description, + }) + .from(tables.transaction) + .where( + and( + eq(tables.transaction.organizationId, orgId), + inArray(tables.transaction.type, [ + "dev_plan_start", + "dev_plan_upgrade", + "dev_plan_downgrade", + "dev_plan_cancel", + "dev_plan_end", + "dev_plan_renewal", + ]), + ), + ) + .orderBy(desc(tables.transaction.createdAt)) + .limit(100); + + const paymentFailures = await db + .select({ + id: tables.paymentFailure.id, + createdAt: tables.paymentFailure.createdAt, + amount: tables.paymentFailure.amount, + currency: tables.paymentFailure.currency, + declineCode: tables.paymentFailure.declineCode, + failureMessage: tables.paymentFailure.failureMessage, + source: tables.paymentFailure.source, + }) + .from(tables.paymentFailure) + .where(eq(tables.paymentFailure.organizationId, orgId)) + .orderBy(desc(tables.paymentFailure.createdAt)) + .limit(50); + + return c.json({ + subscriber, + transactions: transactions.map((t) => ({ + id: t.id, + createdAt: t.createdAt.toISOString(), + type: t.type, + amount: t.amount ?? null, + creditAmount: t.creditAmount ?? null, + currency: t.currency, + status: t.status, + description: t.description ?? null, + })), + paymentFailures: paymentFailures.map((p) => ({ + id: p.id, + createdAt: p.createdAt.toISOString(), + amount: p.amount ?? null, + currency: p.currency, + declineCode: p.declineCode ?? null, + failureMessage: p.failureMessage ?? null, + source: p.source ?? null, + })), + }); +}); + export default admin; diff --git a/apps/code/src/lib/api/v1.d.ts b/apps/code/src/lib/api/v1.d.ts index 58bbaa2e1e..5633cec320 100644 --- a/apps/code/src/lib/api/v1.d.ts +++ b/apps/code/src/lib/api/v1.d.ts @@ -4304,6 +4304,194 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/devpass": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: { + limit?: number; + offset?: number | null; + search?: string; + tier?: "lite" | "pro" | "max" | "none"; + status?: "active" | "cancelled_pending" | "expired" | "churned"; + utilization?: "low" | "healthy" | "high" | "over"; + marginNegative?: boolean | null; + showChurned?: boolean | null; + sortBy?: "name" | "billingEmail" | "tier" | "createdAt" | "cycleStart" | "expiresAt" | "subscribedSince" | "utilizationPct" | "realCost" | "margin" | "mrr" | "creditsUsed"; + sortOrder?: "asc" | "desc"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of DevPass subscribers. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + subscribers: { + id: string; + name: string; + billingEmail: string; + ownerUserId: string | null; + ownerName: string | null; + ownerEmail: string | null; + /** @enum {string} */ + tier: "lite" | "pro" | "max" | "none"; + /** @enum {string} */ + status: "active" | "cancelled_pending" | "expired" | "churned"; + hasPaymentIssue: boolean; + creditsUsed: string; + creditsLimit: string; + utilizationPct: number | null; + cycleStart: string | null; + cycleDaysIn: number | null; + expiresAt: string | null; + cancelled: boolean; + allowAllModels: boolean; + mrr: number; + realCost: number; + margin: number; + subscribedSince: string | null; + tierChanges: number; + lastPaymentFailureAt: string | null; + createdAt: string; + }[]; + total: number; + kpis: { + activeByTier: { + lite: number; + pro: number; + max: number; + }; + totalActive: number; + cancelledPending: number; + churned: number; + grossMrr: number; + startsThisMonth: number; + endsThisMonth: number; + netNewThisMonth: number; + weightedAvgUtilization: number; + totalRealCostCycle: number; + totalMrrCycle: number; + totalMargin: number; + }; + limit: number; + offset: number; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/devpass/{orgId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + orgId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description DevPass subscriber detail. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + subscriber: { + id: string; + name: string; + billingEmail: string; + ownerUserId: string | null; + ownerName: string | null; + ownerEmail: string | null; + /** @enum {string} */ + tier: "lite" | "pro" | "max" | "none"; + /** @enum {string} */ + status: "active" | "cancelled_pending" | "expired" | "churned"; + hasPaymentIssue: boolean; + creditsUsed: string; + creditsLimit: string; + utilizationPct: number | null; + cycleStart: string | null; + cycleDaysIn: number | null; + expiresAt: string | null; + cancelled: boolean; + allowAllModels: boolean; + mrr: number; + realCost: number; + margin: number; + subscribedSince: string | null; + tierChanges: number; + lastPaymentFailureAt: string | null; + createdAt: string; + }; + transactions: { + id: string; + createdAt: string; + type: string; + amount: string | null; + creditAmount: string | null; + currency: string; + status: string; + description: string | null; + }[]; + paymentFailures: { + id: string; + createdAt: string; + amount: string | null; + currency: string; + declineCode: string | null; + failureMessage: string | null; + source: string | null; + }[]; + }; + }; + }; + /** @description Subscriber not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/keys/api": { parameters: { query?: never; diff --git a/apps/playground/src/lib/api/v1.d.ts b/apps/playground/src/lib/api/v1.d.ts index 58bbaa2e1e..5633cec320 100644 --- a/apps/playground/src/lib/api/v1.d.ts +++ b/apps/playground/src/lib/api/v1.d.ts @@ -4304,6 +4304,194 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/devpass": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: { + limit?: number; + offset?: number | null; + search?: string; + tier?: "lite" | "pro" | "max" | "none"; + status?: "active" | "cancelled_pending" | "expired" | "churned"; + utilization?: "low" | "healthy" | "high" | "over"; + marginNegative?: boolean | null; + showChurned?: boolean | null; + sortBy?: "name" | "billingEmail" | "tier" | "createdAt" | "cycleStart" | "expiresAt" | "subscribedSince" | "utilizationPct" | "realCost" | "margin" | "mrr" | "creditsUsed"; + sortOrder?: "asc" | "desc"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of DevPass subscribers. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + subscribers: { + id: string; + name: string; + billingEmail: string; + ownerUserId: string | null; + ownerName: string | null; + ownerEmail: string | null; + /** @enum {string} */ + tier: "lite" | "pro" | "max" | "none"; + /** @enum {string} */ + status: "active" | "cancelled_pending" | "expired" | "churned"; + hasPaymentIssue: boolean; + creditsUsed: string; + creditsLimit: string; + utilizationPct: number | null; + cycleStart: string | null; + cycleDaysIn: number | null; + expiresAt: string | null; + cancelled: boolean; + allowAllModels: boolean; + mrr: number; + realCost: number; + margin: number; + subscribedSince: string | null; + tierChanges: number; + lastPaymentFailureAt: string | null; + createdAt: string; + }[]; + total: number; + kpis: { + activeByTier: { + lite: number; + pro: number; + max: number; + }; + totalActive: number; + cancelledPending: number; + churned: number; + grossMrr: number; + startsThisMonth: number; + endsThisMonth: number; + netNewThisMonth: number; + weightedAvgUtilization: number; + totalRealCostCycle: number; + totalMrrCycle: number; + totalMargin: number; + }; + limit: number; + offset: number; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/devpass/{orgId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + orgId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description DevPass subscriber detail. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + subscriber: { + id: string; + name: string; + billingEmail: string; + ownerUserId: string | null; + ownerName: string | null; + ownerEmail: string | null; + /** @enum {string} */ + tier: "lite" | "pro" | "max" | "none"; + /** @enum {string} */ + status: "active" | "cancelled_pending" | "expired" | "churned"; + hasPaymentIssue: boolean; + creditsUsed: string; + creditsLimit: string; + utilizationPct: number | null; + cycleStart: string | null; + cycleDaysIn: number | null; + expiresAt: string | null; + cancelled: boolean; + allowAllModels: boolean; + mrr: number; + realCost: number; + margin: number; + subscribedSince: string | null; + tierChanges: number; + lastPaymentFailureAt: string | null; + createdAt: string; + }; + transactions: { + id: string; + createdAt: string; + type: string; + amount: string | null; + creditAmount: string | null; + currency: string; + status: string; + description: string | null; + }[]; + paymentFailures: { + id: string; + createdAt: string; + amount: string | null; + currency: string; + declineCode: string | null; + failureMessage: string | null; + source: string | null; + }[]; + }; + }; + }; + /** @description Subscriber not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/keys/api": { parameters: { query?: never; diff --git a/apps/ui/src/lib/api/v1.d.ts b/apps/ui/src/lib/api/v1.d.ts index 58bbaa2e1e..5633cec320 100644 --- a/apps/ui/src/lib/api/v1.d.ts +++ b/apps/ui/src/lib/api/v1.d.ts @@ -4304,6 +4304,194 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/devpass": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: { + limit?: number; + offset?: number | null; + search?: string; + tier?: "lite" | "pro" | "max" | "none"; + status?: "active" | "cancelled_pending" | "expired" | "churned"; + utilization?: "low" | "healthy" | "high" | "over"; + marginNegative?: boolean | null; + showChurned?: boolean | null; + sortBy?: "name" | "billingEmail" | "tier" | "createdAt" | "cycleStart" | "expiresAt" | "subscribedSince" | "utilizationPct" | "realCost" | "margin" | "mrr" | "creditsUsed"; + sortOrder?: "asc" | "desc"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of DevPass subscribers. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + subscribers: { + id: string; + name: string; + billingEmail: string; + ownerUserId: string | null; + ownerName: string | null; + ownerEmail: string | null; + /** @enum {string} */ + tier: "lite" | "pro" | "max" | "none"; + /** @enum {string} */ + status: "active" | "cancelled_pending" | "expired" | "churned"; + hasPaymentIssue: boolean; + creditsUsed: string; + creditsLimit: string; + utilizationPct: number | null; + cycleStart: string | null; + cycleDaysIn: number | null; + expiresAt: string | null; + cancelled: boolean; + allowAllModels: boolean; + mrr: number; + realCost: number; + margin: number; + subscribedSince: string | null; + tierChanges: number; + lastPaymentFailureAt: string | null; + createdAt: string; + }[]; + total: number; + kpis: { + activeByTier: { + lite: number; + pro: number; + max: number; + }; + totalActive: number; + cancelledPending: number; + churned: number; + grossMrr: number; + startsThisMonth: number; + endsThisMonth: number; + netNewThisMonth: number; + weightedAvgUtilization: number; + totalRealCostCycle: number; + totalMrrCycle: number; + totalMargin: number; + }; + limit: number; + offset: number; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/devpass/{orgId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + orgId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description DevPass subscriber detail. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + subscriber: { + id: string; + name: string; + billingEmail: string; + ownerUserId: string | null; + ownerName: string | null; + ownerEmail: string | null; + /** @enum {string} */ + tier: "lite" | "pro" | "max" | "none"; + /** @enum {string} */ + status: "active" | "cancelled_pending" | "expired" | "churned"; + hasPaymentIssue: boolean; + creditsUsed: string; + creditsLimit: string; + utilizationPct: number | null; + cycleStart: string | null; + cycleDaysIn: number | null; + expiresAt: string | null; + cancelled: boolean; + allowAllModels: boolean; + mrr: number; + realCost: number; + margin: number; + subscribedSince: string | null; + tierChanges: number; + lastPaymentFailureAt: string | null; + createdAt: string; + }; + transactions: { + id: string; + createdAt: string; + type: string; + amount: string | null; + creditAmount: string | null; + currency: string; + status: string; + description: string | null; + }[]; + paymentFailures: { + id: string; + createdAt: string; + amount: string | null; + currency: string; + declineCode: string | null; + failureMessage: string | null; + source: string | null; + }[]; + }; + }; + }; + /** @description Subscriber not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/keys/api": { parameters: { query?: never; diff --git a/ee/admin/src/app/devpass/[orgId]/page.tsx b/ee/admin/src/app/devpass/[orgId]/page.tsx new file mode 100644 index 0000000000..a20dbed67b --- /dev/null +++ b/ee/admin/src/app/devpass/[orgId]/page.tsx @@ -0,0 +1,432 @@ +import { + AlertCircle, + ArrowLeft, + Building2, + CreditCard, + Receipt, +} from "lucide-react"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { requireSession } from "@/lib/require-session"; +import { createServerApiClient } from "@/lib/server-api"; +import { cn } from "@/lib/utils"; + +const currencyFormatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 2, +}); + +const currencyFormatterPrecise = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 4, +}); + +function formatDate(dateString: string | null) { + if (!dateString) { + return "—"; + } + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function formatDateTime(dateString: string) { + return new Date(dateString).toLocaleString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatTransactionType(type: string) { + return type.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()); +} + +function getTransactionTypeBadgeVariant( + type: string, +): "default" | "secondary" | "outline" | "destructive" { + if (type.includes("cancel") || type.includes("end")) { + return "destructive"; + } + if (type.includes("start") || type.includes("renewal")) { + return "default"; + } + if (type.includes("upgrade") || type.includes("downgrade")) { + return "secondary"; + } + return "outline"; +} + +function getTierBadgeVariant( + tier: string, +): "default" | "secondary" | "outline" { + switch (tier) { + case "max": + return "default"; + case "pro": + return "secondary"; + case "lite": + return "outline"; + default: + return "outline"; + } +} + +function getStatusBadgeVariant( + status: string, +): "default" | "secondary" | "outline" | "destructive" { + switch (status) { + case "active": + return "secondary"; + case "cancelled_pending": + return "outline"; + case "expired": + return "destructive"; + case "churned": + return "outline"; + default: + return "outline"; + } +} + +function formatStatus(status: string) { + if (status === "cancelled_pending") { + return "cancel pending"; + } + return status; +} + +function SignInPrompt() { + return ( +
+
+
+

+ Admin Dashboard +

+

+ Sign in to access the admin dashboard +

+
+ +
+
+ ); +} + +export default async function DevpassDetailPage({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + await requireSession(); + + const { orgId } = await params; + + const $api = await createServerApiClient(); + const { data } = await $api.GET("/admin/devpass/{orgId}", { + params: { path: { orgId } }, + }); + + if (data === null) { + return ; + } + + if (!data) { + notFound(); + } + + const sub = data.subscriber; + const utilizationClamped = + sub.utilizationPct === null + ? 0 + : Math.min(100, Math.max(0, sub.utilizationPct)); + const utilizationTone = + sub.utilizationPct === null + ? "bg-muted" + : sub.utilizationPct < 20 + ? "bg-amber-500" + : sub.utilizationPct > 100 + ? "bg-rose-500" + : sub.utilizationPct > 80 + ? "bg-orange-500" + : "bg-emerald-500"; + + return ( +
+
+ +
+ +
+
+
+
+ +
+
+

+ {sub.name} +

+

{sub.id}

+
+
+
+ {sub.ownerEmail ?? sub.billingEmail} + • + Subscribed {formatDate(sub.subscribedSince)} +
+
+ + Tier: {sub.tier} + + + {formatStatus(sub.status)} + + {sub.hasPaymentIssue && ( + + + payment issue + + )} + {sub.allowAllModels && ( + all-models access + )} +
+
+ +
+ +
+
+
+ Cycle utilization +
+
+ {sub.utilizationPct === null + ? "—" + : `${sub.utilizationPct.toFixed(1)}%`} +
+
+
+
+
+ {currencyFormatter.format(parseFloat(sub.creditsUsed))} of{" "} + {currencyFormatter.format(parseFloat(sub.creditsLimit))} +
+
+ {sub.cycleDaysIn !== null + ? `Day ${sub.cycleDaysIn} of cycle` + : "No active cycle"} +
+
+
+
+ MRR +
+
+ {currencyFormatter.format(sub.mrr)} +
+
+ Renews {formatDate(sub.expiresAt)} +
+
+
+
+ Real provider cost (cycle) +
+
+ {currencyFormatterPrecise.format(sub.realCost)} +
+
+ From hourly project stats +
+
+
+
+ Margin +
+
+ {currencyFormatter.format(sub.margin)} +
+
+ {sub.tierChanges} tier change + {sub.tierChanges === 1 ? "" : "s"} all time +
+
+
+ + + + + + Subscription history ({data.transactions.length}) + + + + Payment failures ({data.paymentFailures.length}) + + + + +
+ + + + Date + Event + Amount + Credits + Status + Description + + + + {data.transactions.length === 0 ? ( + + + No subscription events recorded + + + ) : ( + data.transactions.map((t) => ( + + + {formatDateTime(t.createdAt)} + + + + {formatTransactionType(t.type)} + + + + {t.amount + ? currencyFormatter.format(parseFloat(t.amount)) + : "—"} + + + {t.creditAmount + ? currencyFormatter.format(parseFloat(t.creditAmount)) + : "—"} + + + + {t.status} + + + + {t.description ?? "—"} + + + )) + )} + +
+
+
+ + +
+ + + + Date + Amount + Decline code + Source + Message + + + + {data.paymentFailures.length === 0 ? ( + + + No payment failures + + + ) : ( + data.paymentFailures.map((p) => ( + + + {formatDateTime(p.createdAt)} + + + {p.amount + ? currencyFormatter.format(parseFloat(p.amount)) + : "—"} + + + {p.declineCode ? ( + {p.declineCode} + ) : ( + — + )} + + + {p.source ?? "—"} + + + {p.failureMessage ?? "—"} + + + )) + )} + +
+
+
+
+
+ ); +} diff --git a/ee/admin/src/app/devpass/page.tsx b/ee/admin/src/app/devpass/page.tsx new file mode 100644 index 0000000000..e069865aaa --- /dev/null +++ b/ee/admin/src/app/devpass/page.tsx @@ -0,0 +1,879 @@ +import { + AlertCircle, + ArrowDown, + ArrowUp, + ArrowUpDown, + ChevronLeft, + ChevronRight, + Search, + TrendingDown, + TrendingUp, + Users, + Wallet, +} from "lucide-react"; +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { requireSession } from "@/lib/require-session"; +import { createServerApiClient } from "@/lib/server-api"; +import { cn } from "@/lib/utils"; + +const SORT_BY_VALUES = [ + "name", + "billingEmail", + "tier", + "createdAt", + "cycleStart", + "expiresAt", + "subscribedSince", + "utilizationPct", + "realCost", + "margin", + "mrr", + "creditsUsed", +] as const; +type SortBy = (typeof SORT_BY_VALUES)[number]; + +const SORT_ORDER_VALUES = ["asc", "desc"] as const; +type SortOrder = (typeof SORT_ORDER_VALUES)[number]; + +const TIER_VALUES = ["lite", "pro", "max", "none"] as const; +type TierFilter = (typeof TIER_VALUES)[number] | ""; + +const STATUS_VALUES = [ + "active", + "cancelled_pending", + "expired", + "churned", +] as const; +type StatusFilter = (typeof STATUS_VALUES)[number] | ""; + +const UTIL_VALUES = ["low", "healthy", "high", "over"] as const; +type UtilFilter = (typeof UTIL_VALUES)[number] | ""; + +function pickEnum( + allowed: T, + raw: string | undefined, + fallback: T[number] | "", +): T[number] | "" { + if (!raw) { + return fallback; + } + return (allowed as readonly string[]).includes(raw) + ? (raw as T[number]) + : fallback; +} + +const currencyFormatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 2, +}); + +const currencyFormatterPrecise = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 4, +}); + +function formatDate(dateString: string | null) { + if (!dateString) { + return "—"; + } + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function getTierBadgeVariant( + tier: string, +): "default" | "secondary" | "outline" { + switch (tier) { + case "max": + return "default"; + case "pro": + return "secondary"; + case "lite": + return "outline"; + default: + return "outline"; + } +} + +function getStatusBadgeVariant( + status: string, +): "default" | "secondary" | "outline" | "destructive" { + switch (status) { + case "active": + return "secondary"; + case "cancelled_pending": + return "outline"; + case "expired": + return "destructive"; + case "churned": + return "outline"; + default: + return "outline"; + } +} + +function formatStatus(status: string) { + if (status === "cancelled_pending") { + return "cancel pending"; + } + return status; +} + +function SortableHeader({ + label, + sortKey, + currentSortBy, + currentSortOrder, + queryString, +}: { + label: string; + sortKey: SortBy; + currentSortBy: SortBy; + currentSortOrder: SortOrder; + queryString: string; +}) { + const isActive = currentSortBy === sortKey; + const nextOrder = isActive && currentSortOrder === "asc" ? "desc" : "asc"; + + const baseParams = new URLSearchParams(queryString); + baseParams.set("sortBy", sortKey); + baseParams.set("sortOrder", nextOrder); + baseParams.set("page", "1"); + const href = `/devpass?${baseParams.toString()}`; + + return ( + + {label} + {isActive ? ( + currentSortOrder === "asc" ? ( + + ) : ( + + ) + ) : ( + + )} + + ); +} + +function UtilBar({ pct }: { pct: number | null }) { + if (pct === null) { + return —; + } + const clamped = Math.min(100, Math.max(0, pct)); + const tone = + pct < 20 + ? "bg-amber-500" + : pct > 100 + ? "bg-rose-500" + : pct > 80 + ? "bg-orange-500" + : "bg-emerald-500"; + return ( +
+
+
+
+ + {pct.toFixed(0)}% + +
+ ); +} + +function FilterPill({ + label, + value, + currentValue, + queryString, + paramName, +}: { + label: string; + value: string; + currentValue: string; + queryString: string; + paramName: string; +}) { + const params = new URLSearchParams(queryString); + if (currentValue === value) { + params.delete(paramName); + } else { + params.set(paramName, value); + } + params.set("page", "1"); + const isActive = currentValue === value; + return ( + + {label} + + ); +} + +function ToggleLink({ + label, + value, + queryString, + paramName, +}: { + label: string; + value: boolean; + queryString: string; + paramName: string; +}) { + const params = new URLSearchParams(queryString); + if (value) { + params.delete(paramName); + } else { + params.set(paramName, "true"); + } + params.set("page", "1"); + return ( + + {label} + + ); +} + +function SignInPrompt() { + return ( +
+
+
+

+ Admin Dashboard +

+

+ Sign in to access the admin dashboard +

+
+ +
+
+ ); +} + +export default async function DevpassPage({ + searchParams, +}: { + searchParams?: Promise<{ + page?: string; + search?: string; + sortBy?: string; + sortOrder?: string; + tier?: string; + status?: string; + utilization?: string; + marginNegative?: string; + showChurned?: string; + }>; +}) { + await requireSession(); + + const params = await searchParams; + const rawPage = parseInt(params?.page ?? "1", 10); + const page = Number.isFinite(rawPage) && rawPage >= 1 ? rawPage : 1; + const search = params?.search ?? ""; + const sortBy = + (pickEnum(SORT_BY_VALUES, params?.sortBy, "subscribedSince") as SortBy) || + "subscribedSince"; + const sortOrder = + (pickEnum(SORT_ORDER_VALUES, params?.sortOrder, "desc") as SortOrder) || + "desc"; + const tier = pickEnum(TIER_VALUES, params?.tier, "") as TierFilter; + const status = pickEnum(STATUS_VALUES, params?.status, "") as StatusFilter; + const utilization = pickEnum( + UTIL_VALUES, + params?.utilization, + "", + ) as UtilFilter; + const marginNegative = params?.marginNegative === "true"; + const showChurned = params?.showChurned === "true"; + const limit = 25; + const offset = (page - 1) * limit; + + const $api = await createServerApiClient(); + const { data } = await $api.GET("/admin/devpass", { + params: { + query: { + limit, + offset, + search: search || undefined, + tier: tier || undefined, + status: status || undefined, + utilization: utilization || undefined, + marginNegative: marginNegative || undefined, + showChurned, + sortBy, + sortOrder, + }, + }, + }); + + if (!data) { + return ; + } + + const totalPages = Math.ceil(data.total / limit); + + const queryParams = new URLSearchParams(); + if (search) { + queryParams.set("search", search); + } + if (tier) { + queryParams.set("tier", tier); + } + if (status) { + queryParams.set("status", status); + } + if (utilization) { + queryParams.set("utilization", utilization); + } + if (marginNegative) { + queryParams.set("marginNegative", "true"); + } + if (showChurned) { + queryParams.set("showChurned", "true"); + } + queryParams.set("sortBy", sortBy); + queryParams.set("sortOrder", sortOrder); + const queryString = queryParams.toString(); + + async function handleSearch(formData: FormData) { + "use server"; + const searchValue = formData.get("search") as string; + const sortByValue = formData.get("sortBy") as string; + const sortOrderValue = formData.get("sortOrder") as string; + const tierValue = formData.get("tier") as string; + const statusValue = formData.get("status") as string; + const utilValue = formData.get("utilization") as string; + const marginValue = formData.get("marginNegative") as string; + const churnValue = formData.get("showChurned") as string; + const sp = new URLSearchParams(); + if (searchValue) { + sp.set("search", searchValue); + } + if (tierValue) { + sp.set("tier", tierValue); + } + if (statusValue) { + sp.set("status", statusValue); + } + if (utilValue) { + sp.set("utilization", utilValue); + } + if (marginValue) { + sp.set("marginNegative", "true"); + } + if (churnValue) { + sp.set("showChurned", "true"); + } + sp.set("sortBy", sortByValue); + sp.set("sortOrder", sortOrderValue); + sp.set("page", "1"); + redirect(`/devpass?${sp.toString()}`); + } + + const kpis = data.kpis; + + return ( +
+
+

DevPass

+

+ Subscribers across Lite, Pro and Max — current cycle utilization, real + provider cost, and margin. +

+
+ +
+
+
+ + Active subscribers +
+
+ {kpis.totalActive} +
+
+ Lite {kpis.activeByTier.lite} · Pro {kpis.activeByTier.pro} · Max{" "} + {kpis.activeByTier.max} +
+
+
+
+ + Gross MRR +
+
+ {currencyFormatter.format(kpis.grossMrr)} +
+
+ Net new this month:{" "} + 0 + ? "text-emerald-600 dark:text-emerald-400" + : kpis.netNewThisMonth < 0 + ? "text-rose-600 dark:text-rose-400" + : "", + )} + > + {kpis.netNewThisMonth > 0 ? "+" : ""} + {kpis.netNewThisMonth} + {" "} + ({kpis.startsThisMonth} starts / {kpis.endsThisMonth} ends) +
+
+
+
+ + Avg utilization +
+
+ {kpis.weightedAvgUtilization.toFixed(1)}% +
+
+ Weighted across active subs +
+
+
+
+ {kpis.totalMargin >= 0 ? ( + + ) : ( + + )} + Cycle margin +
+
+ {currencyFormatter.format(kpis.totalMargin)} +
+
+ {currencyFormatter.format(kpis.totalRealCostCycle)} provider cost + this cycle +
+
+
+ +
+ + + + + + + +
+
+ + +
+ +
+
+ + Tier + + + + +
+
+ + Status + + + + + +
+
+ + Utilization + + + + + +
+
+ + Other + + + +
+

+ {data.total} subscriber{data.total === 1 ? "" : "s"} match current + filters +

+
+ +
+ + + + + + + + + + Status + + + + + + + + + + + + + + + + + + + + + + Δ tier + Payments + + + + {data.subscribers.length === 0 ? ( + + + No subscribers match + + + ) : ( + data.subscribers.map((sub) => ( + + + + {sub.name} + +

+ {sub.ownerEmail ?? sub.billingEmail} +

+
+ + + {sub.tier} + + + + + {formatStatus(sub.status)} + + + + +

+ {currencyFormatter.format(parseFloat(sub.creditsUsed))} /{" "} + {currencyFormatter.format(parseFloat(sub.creditsLimit))} +

+
+ + {sub.cycleDaysIn !== null ? `Day ${sub.cycleDaysIn}` : "—"} + + + {sub.expiresAt ? formatDate(sub.expiresAt) : "—"} + + + {currencyFormatter.format(sub.mrr)} + + + {currencyFormatterPrecise.format(sub.realCost)} + + + {currencyFormatter.format(sub.margin)} + + + {formatDate(sub.subscribedSince)} + + + {sub.tierChanges} + + + {sub.hasPaymentIssue ? ( + + + failed + + ) : ( + ok + )} + +
+ )) + )} +
+
+
+ + {totalPages > 1 && ( +
+

+ Showing {offset + 1} to {Math.min(offset + limit, data.total)} of{" "} + {data.total} +

+
+ + + Page {page} of {totalPages} + + +
+
+ )} +
+ ); +} diff --git a/ee/admin/src/components/admin-shell.tsx b/ee/admin/src/components/admin-shell.tsx index 414a582d46..46c011cf90 100644 --- a/ee/admin/src/components/admin-shell.tsx +++ b/ee/admin/src/components/admin-shell.tsx @@ -14,6 +14,7 @@ import { MessageCircle, Percent, Server, + Sparkles, } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; @@ -83,6 +84,7 @@ export function AdminShell({ children }: AdminShellProps) { const isDashboard = pathname === "/" || pathname === ""; const isOrganizations = pathname.startsWith("/organizations"); + const isDevpass = pathname.startsWith("/devpass"); const isDiscounts = pathname === "/discounts"; const isRateLimits = pathname === "/rate-limits"; const isProviders = pathname === "/providers"; @@ -144,6 +146,14 @@ export function AdminShell({ children }: AdminShellProps) { + + + + + DevPass + + + diff --git a/ee/admin/src/lib/api/v1.d.ts b/ee/admin/src/lib/api/v1.d.ts index 58bbaa2e1e..5633cec320 100644 --- a/ee/admin/src/lib/api/v1.d.ts +++ b/ee/admin/src/lib/api/v1.d.ts @@ -4304,6 +4304,194 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/devpass": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: { + limit?: number; + offset?: number | null; + search?: string; + tier?: "lite" | "pro" | "max" | "none"; + status?: "active" | "cancelled_pending" | "expired" | "churned"; + utilization?: "low" | "healthy" | "high" | "over"; + marginNegative?: boolean | null; + showChurned?: boolean | null; + sortBy?: "name" | "billingEmail" | "tier" | "createdAt" | "cycleStart" | "expiresAt" | "subscribedSince" | "utilizationPct" | "realCost" | "margin" | "mrr" | "creditsUsed"; + sortOrder?: "asc" | "desc"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of DevPass subscribers. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + subscribers: { + id: string; + name: string; + billingEmail: string; + ownerUserId: string | null; + ownerName: string | null; + ownerEmail: string | null; + /** @enum {string} */ + tier: "lite" | "pro" | "max" | "none"; + /** @enum {string} */ + status: "active" | "cancelled_pending" | "expired" | "churned"; + hasPaymentIssue: boolean; + creditsUsed: string; + creditsLimit: string; + utilizationPct: number | null; + cycleStart: string | null; + cycleDaysIn: number | null; + expiresAt: string | null; + cancelled: boolean; + allowAllModels: boolean; + mrr: number; + realCost: number; + margin: number; + subscribedSince: string | null; + tierChanges: number; + lastPaymentFailureAt: string | null; + createdAt: string; + }[]; + total: number; + kpis: { + activeByTier: { + lite: number; + pro: number; + max: number; + }; + totalActive: number; + cancelledPending: number; + churned: number; + grossMrr: number; + startsThisMonth: number; + endsThisMonth: number; + netNewThisMonth: number; + weightedAvgUtilization: number; + totalRealCostCycle: number; + totalMrrCycle: number; + totalMargin: number; + }; + limit: number; + offset: number; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/devpass/{orgId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + orgId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description DevPass subscriber detail. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + subscriber: { + id: string; + name: string; + billingEmail: string; + ownerUserId: string | null; + ownerName: string | null; + ownerEmail: string | null; + /** @enum {string} */ + tier: "lite" | "pro" | "max" | "none"; + /** @enum {string} */ + status: "active" | "cancelled_pending" | "expired" | "churned"; + hasPaymentIssue: boolean; + creditsUsed: string; + creditsLimit: string; + utilizationPct: number | null; + cycleStart: string | null; + cycleDaysIn: number | null; + expiresAt: string | null; + cancelled: boolean; + allowAllModels: boolean; + mrr: number; + realCost: number; + margin: number; + subscribedSince: string | null; + tierChanges: number; + lastPaymentFailureAt: string | null; + createdAt: string; + }; + transactions: { + id: string; + createdAt: string; + type: string; + amount: string | null; + creditAmount: string | null; + currency: string; + status: string; + description: string | null; + }[]; + paymentFailures: { + id: string; + createdAt: string; + amount: string | null; + currency: string; + declineCode: string | null; + failureMessage: string | null; + source: string | null; + }[]; + }; + }; + }; + /** @description Subscriber not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/keys/api": { parameters: { query?: never;