Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 211 additions & 2 deletions apps/api/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7393,6 +7393,7 @@ const devpassSubscriberSchema = z.object({
mrr: z.number(),
realCost: z.number(),
margin: z.number(),
marginPct: z.number().nullable(),
subscribedSince: z.string().nullable(),
tierChanges: z.number(),
lastPaymentFailureAt: z.string().nullable(),
Expand All @@ -7416,6 +7417,7 @@ const devpassKpisSchema = z.object({
totalRealCostCycle: z.number(),
totalMrrCycle: z.number(),
totalMargin: z.number(),
marginPct: z.number().nullable(),
});

const devpassListSchema = z.object({
Expand Down Expand Up @@ -7522,6 +7524,47 @@ const getDevpassSubscriber = createRoute({
},
});

const devpassTimeseriesPointSchema = z.object({
date: z.string(),
revenue: z.number(),
cost: z.number(),
margin: z.number(),
});

const devpassTimeseriesSchema = z.object({
data: z.array(devpassTimeseriesPointSchema),
totals: z.object({
revenue: z.number(),
cost: z.number(),
margin: z.number(),
}),
range: z.object({
from: z.string(),
to: z.string(),
}),
});

const getDevpassTimeseries = createRoute({
method: "get",
path: "/devpass/timeseries",
request: {
query: z.object({
from: z.string().optional(),
to: z.string().optional(),
}),
Comment on lines +7551 to +7554

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate from/to together on the new DevPass timeseries route.

The schema accepts arbitrary strings, but the handler only honors the range when both params are present. Right now a partial range silently falls back to all-time, and malformed values can flow into new Date(...)/SQL instead of returning a clean 4xx. Require valid YYYY-MM-DD values and either both params or neither.

Also applies to: 8308-8324

},
responses: {
200: {
content: {
"application/json": {
schema: devpassTimeseriesSchema.openapi({}),
},
},
description: "DevPass revenue/cost/margin per day.",
},
},
});

function tierPriceOf(tier: string): number {
if (tier === "lite" || tier === "pro" || tier === "max") {
return DEV_PLAN_PRICES[tier];
Expand Down Expand Up @@ -7973,6 +8016,10 @@ admin.openapi(getDevpassSubscribers, async (c) => {
: null;
const hasPaymentIssue = (row.paymentFailureCount ?? 0) > 0;

const mrrNum = Number(row.mrr ?? 0);
const marginNum = Number(row.margin ?? 0);
const marginPct = mrrNum > 0 ? (marginNum / mrrNum) * 100 : null;

return {
id: row.id,
name: row.name,
Expand All @@ -7991,9 +8038,10 @@ admin.openapi(getDevpassSubscribers, async (c) => {
expiresAt: expiresAt ? expiresAt.toISOString() : null,
cancelled,
allowAllModels: row.allowAllModels,
mrr: Number(row.mrr ?? 0),
mrr: mrrNum,
realCost: Number(row.realCost ?? 0),
margin: Number(row.margin ?? 0),
margin: marginNum,
marginPct,
subscribedSince: row.subscribedSince
? new Date(row.subscribedSince).toISOString()
: null,
Expand All @@ -8003,6 +8051,9 @@ admin.openapi(getDevpassSubscribers, async (c) => {
};
});

const kpiMarginPct =
totalMrrCycle > 0 ? (totalMargin / totalMrrCycle) * 100 : null;

return c.json({
subscribers,
total,
Expand All @@ -8019,6 +8070,7 @@ admin.openapi(getDevpassSubscribers, async (c) => {
totalRealCostCycle,
totalMrrCycle,
totalMargin,
marginPct: kpiMarginPct,
},
limit,
offset,
Expand Down Expand Up @@ -8135,6 +8187,8 @@ admin.openapi(getDevpassSubscriber, async (c) => {

const hasPaymentIssue = (org.paymentFailureCount ?? 0) > 0;

const marginPct = mrr > 0 ? (margin / mrr) * 100 : null;

const subscriber = {
id: org.id,
name: org.name,
Expand All @@ -8158,6 +8212,7 @@ admin.openapi(getDevpassSubscriber, async (c) => {
mrr,
realCost,
margin,
marginPct,
subscribedSince: firstStartRow?.firstStart
? new Date(firstStartRow.firstStart).toISOString()
: null,
Expand Down Expand Up @@ -8235,4 +8290,158 @@ admin.openapi(getDevpassSubscriber, async (c) => {
});
});

const DEV_PLAN_TX_TYPES = [
"dev_plan_start",
"dev_plan_upgrade",
"dev_plan_downgrade",
"dev_plan_renewal",
] as const;

admin.openapi(getDevpassTimeseries, async (c) => {
const query = c.req.valid("query");
const now = new Date();

// Resolve range. When no from/to is provided, default to all-time
// (anchored to the earliest dev_plan_start, falling back to today).
let startDate: Date;
let endDate: Date;
if (query.from && query.to) {
startDate = new Date(query.from + "T00:00:00.000Z");
endDate = new Date(query.to + "T23:59:59.999Z");
} else {
const [oldest] = await db
.select({
minDate: sql<string>`MIN(${tables.transaction.createdAt})`.as(
"min_date",
),
})
.from(tables.transaction)
.where(eq(tables.transaction.type, "dev_plan_start"));
startDate = oldest?.minDate ? new Date(oldest.minDate) : now;
startDate.setUTCHours(0, 0, 0, 0);
endDate = new Date(now);
endDate.setUTCHours(23, 59, 59, 999);
}

if (endDate.getTime() < startDate.getTime()) {
endDate = new Date(startDate);
endDate.setUTCHours(23, 59, 59, 999);
}

// Revenue per day from completed DevPass transactions.
const revenuePerDay = await db
.select({
date: sql<string>`DATE(${tables.transaction.createdAt})`.as("date"),
total:
sql<string>`COALESCE(SUM(CAST(${tables.transaction.creditAmount} AS NUMERIC)), 0)`.as(
"total",
),
})
.from(tables.transaction)
.where(
and(
eq(tables.transaction.status, "completed"),
inArray(tables.transaction.type, [...DEV_PLAN_TX_TYPES]),
gte(tables.transaction.createdAt, startDate),
lte(tables.transaction.createdAt, endDate),
),
)
.groupBy(sql`DATE(${tables.transaction.createdAt})`)
.orderBy(asc(sql`DATE(${tables.transaction.createdAt})`));

// Provider cost per day for projects belonging to orgs that are or were
// ever on a DevPass plan (i.e. currently devPlan != 'none' OR have a
// historical dev_plan_start). This approximates "DevPass usage" without
// reconstructing daily plan membership.
const costPerDay = await db
.select({
date: sql<string>`DATE(${projectHourlyStats.hourTimestamp})`.as("date"),
total:
sql<string>`COALESCE(SUM(CAST(${projectHourlyStats.cost} AS NUMERIC)), 0)`.as(
"total",
),
})
.from(projectHourlyStats)
.innerJoin(
tables.project,
eq(projectHourlyStats.projectId, tables.project.id),
)
.innerJoin(
tables.organization,
eq(tables.project.organizationId, tables.organization.id),
)
.where(
and(
gte(projectHourlyStats.hourTimestamp, startDate),
lte(projectHourlyStats.hourTimestamp, endDate),
or(
ne(tables.organization.devPlan, "none"),
sql`EXISTS (
SELECT 1 FROM ${tables.transaction} t
WHERE t.organization_id = ${tables.organization.id}
AND t.type = 'dev_plan_start'
)`,
)!,
),
)
.groupBy(sql`DATE(${projectHourlyStats.hourTimestamp})`)
.orderBy(asc(sql`DATE(${projectHourlyStats.hourTimestamp})`));

const revenueMap = new Map<string, number>();
for (const row of revenuePerDay) {
revenueMap.set(row.date, Number(row.total));
}
const costMap = new Map<string, number>();
for (const row of costPerDay) {
costMap.set(row.date, Number(row.total));
}

const data: Array<{
date: string;
revenue: number;
cost: number;
margin: number;
}> = [];

const cursor = new Date(
Date.UTC(
startDate.getUTCFullYear(),
startDate.getUTCMonth(),
startDate.getUTCDate(),
),
);
const lastDay = Date.UTC(
endDate.getUTCFullYear(),
endDate.getUTCMonth(),
endDate.getUTCDate(),
);

let totalRevenue = 0;
let totalCost = 0;

while (cursor.getTime() <= lastDay) {
const iso = cursor.toISOString().slice(0, 10);
const revenue = revenueMap.get(iso) ?? 0;
const cost = costMap.get(iso) ?? 0;
const margin = revenue - cost;
data.push({ date: iso, revenue, cost, margin });
totalRevenue += revenue;
totalCost += cost;
cursor.setUTCDate(cursor.getUTCDate() + 1);
}

return c.json({
data,
totals: {
revenue: totalRevenue,
cost: totalCost,
margin: totalRevenue - totalCost,
},
range: {
from: startDate.toISOString().slice(0, 10),
to: endDate.toISOString().slice(0, 10),
},
});
});

export default admin;
57 changes: 57 additions & 0 deletions apps/code/src/lib/api/v1.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4559,6 +4559,7 @@ export interface paths {
mrr: number;
realCost: number;
margin: number;
marginPct: number | null;
subscribedSince: string | null;
tierChanges: number;
lastPaymentFailureAt: string | null;
Expand All @@ -4582,6 +4583,7 @@ export interface paths {
totalRealCostCycle: number;
totalMrrCycle: number;
totalMargin: number;
marginPct: number | null;
};
limit: number;
offset: number;
Expand Down Expand Up @@ -4646,6 +4648,7 @@ export interface paths {
mrr: number;
realCost: number;
margin: number;
marginPct: number | null;
subscribedSince: string | null;
tierChanges: number;
lastPaymentFailureAt: string | null;
Expand Down Expand Up @@ -4690,6 +4693,60 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/devpass/timeseries": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: {
from?: string;
to?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description DevPass revenue/cost/margin per day. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
date: string;
revenue: number;
cost: number;
margin: number;
}[];
totals: {
revenue: number;
cost: number;
margin: number;
};
range: {
from: string;
to: string;
};
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/keys/api": {
parameters: {
query?: never;
Expand Down
Loading
Loading