diff --git a/apps/api/src/routes/admin-metrics-gross-revenue.spec.ts b/apps/api/src/routes/admin-metrics-gross-revenue.spec.ts index 4b4b7feb54..bcf8fc8b9a 100644 --- a/apps/api/src/routes/admin-metrics-gross-revenue.spec.ts +++ b/apps/api/src/routes/admin-metrics-gross-revenue.spec.ts @@ -16,6 +16,7 @@ interface AdminMetricsResponse { grossRevenue: number; grossCreditsRevenue: number; grossDevpassRevenue: number; + grossResetPassRevenue: number; grossChatPlansRevenue: number; grossProSubscriptionsRevenue: number; } @@ -97,6 +98,16 @@ describe("admin /metrics — gross revenue splits", () => { stripeInvoiceId: "inv_gross_dev_2", createdAt: new Date("2026-02-04T00:00:00Z"), }, + // Reset Pass: one-time PaymentIntent purchase, no invoice. Counted in + // its own split, not in DevPass subscription revenue. + { + organizationId: DEVPASS_ORG_ID, + type: "dev_plan_reset_pass", + amount: "9", + creditAmount: null, + status: "completed", + createdAt: new Date("2026-01-07T00:00:00Z"), + }, // Chat Plan: $10 paid for a plan that includes $150 of virtual // credits — the $150 allowance must never count as revenue. { @@ -134,12 +145,13 @@ describe("admin /metrics — gross revenue splits", () => { // Gross = positive Stripe amounts, refunds ignored: $21 + $11 credits. expect(body.grossCreditsRevenue).toBe(32); - // $20 start + $20 second-cycle renewal. + // $20 start + $20 second-cycle renewal — the Reset Pass is NOT in here. expect(body.grossDevpassRevenue).toBe(40); + expect(body.grossResetPassRevenue).toBe(9); expect(body.grossChatPlansRevenue).toBe(10); // Legacy subscription on a non-devpass org. expect(body.grossProSubscriptionsRevenue).toBe(50); - expect(body.grossRevenue).toBe(132); + expect(body.grossRevenue).toBe(141); // The credits-economy metrics must exclude ALL plan rows: chat plan // creditAmount ($150) is a virtual allowance, not revenue. diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index 9fa87517de..b541118edc 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -10,6 +10,7 @@ import { adminMiddleware } from "@/middleware/admin.js"; import { getStripe } from "@/routes/payments.js"; import { CHAT_PLAN_TX_TYPES, + DEV_PLAN_SUBSCRIPTION_TX_TYPES, DEV_PLAN_TX_TYPES, firstRowPerInvoiceFilter, LEGACY_DEV_PLAN_TX_TYPES, @@ -105,6 +106,7 @@ const adminMetricsSchema = z.object({ grossRevenue: z.number(), grossCreditsRevenue: z.number(), grossDevpassRevenue: z.number(), + grossResetPassRevenue: z.number(), grossChatPlansRevenue: z.number(), grossProSubscriptionsRevenue: z.number(), }); @@ -846,8 +848,10 @@ admin.openapi(getMetrics, async (c) => { const grossCreditsRevenue = Number(grossCreditsRow?.value ?? 0); - // DevPass: dev plan payments (+ legacy `subscription_*` rows on devpass - // orgs), deduplicated per Stripe invoice. Mirrors /admin/devpass/timeseries. + // DevPass: dev plan subscription payments (+ legacy `subscription_*` rows on + // devpass orgs), deduplicated per Stripe invoice. Mirrors + // /admin/devpass/timeseries. One-time Reset Pass purchases are reported as + // their own split below. const [grossDevpassRow] = await db .select({ value: @@ -865,7 +869,7 @@ admin.openapi(getMetrics, async (c) => { eq(tables.transaction.status, "completed"), eq(tables.organization.kind, "devpass"), inArray(tables.transaction.type, [ - ...DEV_PLAN_TX_TYPES, + ...DEV_PLAN_SUBSCRIPTION_TX_TYPES, ...LEGACY_DEV_PLAN_TX_TYPES, ]), firstRowPerInvoiceFilter([ @@ -878,6 +882,32 @@ admin.openapi(getMetrics, async (c) => { const grossDevpassRevenue = Number(grossDevpassRow?.value ?? 0); + // Reset Passes: one-time PaymentIntent purchases on devpass orgs — no + // invoice, so no dedup needed. Gross like the other splits (refunds are + // separate `credit_refund` rows and not netted out here). + const [grossResetPassRow] = await db + .select({ + value: + sql`COALESCE(SUM(CAST(${tables.transaction.amount} AS NUMERIC)), 0)`.as( + "value", + ), + }) + .from(tables.transaction) + .innerJoin( + tables.organization, + eq(tables.transaction.organizationId, tables.organization.id), + ) + .where( + and( + eq(tables.transaction.status, "completed"), + eq(tables.organization.kind, "devpass"), + eq(tables.transaction.type, "dev_plan_reset_pass"), + transactionDateFilter, + ), + ); + + const grossResetPassRevenue = Number(grossResetPassRow?.value ?? 0); + // Chat Plans: plan payments on chat orgs, deduplicated per Stripe invoice. // Mirrors /admin/chat-plans/timeseries. const [grossChatPlansRow] = await db @@ -936,6 +966,7 @@ admin.openapi(getMetrics, async (c) => { const grossRevenue = grossCreditsRevenue + grossDevpassRevenue + + grossResetPassRevenue + grossChatPlansRevenue + grossProSubscriptionsRevenue; @@ -960,6 +991,7 @@ admin.openapi(getMetrics, async (c) => { grossRevenue, grossCreditsRevenue, grossDevpassRevenue, + grossResetPassRevenue, grossChatPlansRevenue, grossProSubscriptionsRevenue, }); diff --git a/apps/api/src/utils/devpass-filter.ts b/apps/api/src/utils/devpass-filter.ts index 16ee564b4a..56426a47cd 100644 --- a/apps/api/src/utils/devpass-filter.ts +++ b/apps/api/src/utils/devpass-filter.ts @@ -45,6 +45,15 @@ export const DEV_PLAN_TX_TYPES = [ "dev_plan_reset_pass", ] as const; +// Subscription-billed dev plan rows only — excludes one-time Reset Pass +// purchases so they can be reported as their own revenue split. +export const DEV_PLAN_SUBSCRIPTION_TX_TYPES = [ + "dev_plan_start", + "dev_plan_upgrade", + "dev_plan_downgrade", + "dev_plan_renewal", +] as const; + // Pre-rename rows for what is now a dev plan. The same `subscription_*` types // are STILL written today for non-personal org Pro subs, so always pair them // with `organization.kind = 'devpass'` to avoid counting org Pro revenue. diff --git a/ee/admin/src/app/devpass/page.tsx b/ee/admin/src/app/devpass/page.tsx index 1459dc8b6f..b6a942efed 100644 --- a/ee/admin/src/app/devpass/page.tsx +++ b/ee/admin/src/app/devpass/page.tsx @@ -481,7 +481,7 @@ export default async function DevpassPage({ -
+
diff --git a/ee/admin/src/app/page.tsx b/ee/admin/src/app/page.tsx index 08ae696f7c..d655d5efc0 100644 --- a/ee/admin/src/app/page.tsx +++ b/ee/admin/src/app/page.tsx @@ -320,6 +320,12 @@ export default async function Page({ label: "DevPass", value: currencyFormatter.format(metrics.grossDevpassRevenue), }, + { + label: "Reset passes", + value: currencyFormatter.format( + metrics.grossResetPassRevenue, + ), + }, { label: "Chat plans", value: currencyFormatter.format(