Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
9 changes: 9 additions & 0 deletions apps/api/src/lib/end-user-session-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface AuthenticatedSession {
/** `test` wallets top up against the Stripe sandbox. */
mode: "live" | "test";
markupPercent: number;
/** Top-up bonus multiplier (percent) funded from the developer org's credits. */
bonusPercent: number;
/** Origins allowed to call with this session (CORS), from the project. */
allowedOrigins: string[] | null;
}
Expand Down Expand Up @@ -98,6 +100,12 @@ export async function endUserSessionAuth(c: Context, next: Next) {
"0",
);

const bonusPercent = Number(
session.wallet.bonusPercentOverride ??
session.wallet.project?.endUserTopUpBonusPercent ??
"0",
);

c.set("endUserSession", {
sessionId: session.id,
walletId: session.wallet.id,
Expand All @@ -106,6 +114,7 @@ export async function endUserSessionAuth(c: Context, next: Next) {
organizationId: session.wallet.organizationId,
mode: session.wallet.mode,
markupPercent: Number.isFinite(markupPercent) ? markupPercent : 0,
bonusPercent: Number.isFinite(bonusPercent) ? bonusPercent : 0,
allowedOrigins: session.wallet.project?.allowedOrigins ?? null,
});

Expand Down
102 changes: 102 additions & 0 deletions apps/api/src/routes/admin-metrics-bonus.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";

import { app } from "@/index.js";
import { createTestUser, deleteAll } from "@/testing.js";

import { db, tables } from "@llmgateway/db";

const ORG_ID = "admin-metrics-org";

interface AdminMetricsResponse {
totalRevenue: number;
totalProcessed: number;
totalToppedUp: number;
totalGiftedCredits: number;
totalBonusCredits: number;
}

describe("admin /metrics — end-user bonus accounting", () => {
let cookie: string;

beforeEach(async () => {
process.env.ADMIN_EMAILS = "admin@example.com";
// createTestUser seeds admin@example.com and returns a session cookie.
cookie = await createTestUser();

await db.insert(tables.organization).values({
id: ORG_ID,
name: "Admin Metrics Org",
billingEmail: "am@example.com",
});

await db.insert(tables.transaction).values([
// Real org credit purchase — the only true revenue.
{
organizationId: ORG_ID,
type: "credit_topup",
amount: "21",
creditAmount: "20",
status: "completed",
},
// Developer-funded end-user bonus grant: negative creditAmount. Must NOT
// reduce revenue; must be reported as bonus credits granted.
{
organizationId: ORG_ID,
type: "end_user_bonus",
amount: "5",
creditAmount: "-5",
status: "completed",
},
// End-user developer-margin accrual: must NOT count as revenue.
{
organizationId: ORG_ID,
type: "end_user_margin_accrual",
amount: "2",
creditAmount: "2",
status: "completed",
},
// End-user wallet top-up: the real payment — DOES count as revenue,
// just like an org credit purchase.
{
organizationId: ORG_ID,
type: "end_user_topup",
amount: "11",
creditAmount: "10",
status: "completed",
},
// Gifted credits: already excluded from revenue, tracked separately.
{
organizationId: ORG_ID,
type: "credit_gift",
amount: "3",
creditAmount: "3",
status: "completed",
},
]);
});

afterEach(async () => {
await deleteAll();
});

test("counts top-ups as revenue, excludes bonus/margin, reports bonus separately", async () => {
const res = await app.request("/admin/metrics", {
headers: { Cookie: cookie },
});
expect(res.status).toBe(200);
const body = (await res.json()) as AdminMetricsResponse;

// Revenue = org credit purchase ($20) + end-user top-up ($10). The gift,
// bonus (-5), and developer margin are all excluded (previously the -5
// bonus would have deflated this).
expect(body.totalRevenue).toBe(30);
// Processed = gross of both real payments ($21 + $11); bonus/margin/gift out.
expect(body.totalProcessed).toBe(32);
// Topped up (org credit economy) = purchases + gifts (23); all end-user
// wallet rows, including the top-up, are excluded here.
expect(body.totalToppedUp).toBe(23);
// Bonus credits granted, reported as a positive figure.
expect(body.totalBonusCredits).toBe(5);
expect(body.totalGiftedCredits).toBe(3);
});
});
50 changes: 46 additions & 4 deletions apps/api/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import { maskToken } from "@/lib/maskToken.js";
import { parseReferralBonusPercent } from "@/lib/referral-bonus.js";
import { adminMiddleware } from "@/middleware/admin.js";
import { getStripe } from "@/routes/payments.js";
import { notDevpassFilter } from "@/utils/devpass-filter.js";
import {
notDevpassFilter,
notEndUserNonRevenueFilter,
notEndUserWalletFilter,
} from "@/utils/devpass-filter.js";
import {
HOURLY_BUCKET_THRESHOLD_MINUTES,
floorToHourStart,
Expand Down Expand Up @@ -89,6 +93,7 @@ const adminMetricsSchema = z.object({
unusedCredits: z.number(),
overage: z.number(),
totalGiftedCredits: z.number(),
totalBonusCredits: z.number(),
totalRefunds: z.number(),
});

Expand Down Expand Up @@ -599,8 +604,10 @@ admin.openapi(getMetrics, async (c) => {

const payingCustomers = Number(payingRow?.count ?? 0);

// Total revenue (completed credit-purchase transactions, excluding gifts
// and all DevPass subscription rows, using creditAmount to exclude Stripe fees)
// Total revenue: completed credit-purchase rows — org credit top-ups AND
// end-user wallet top-ups (`end_user_topup`, reversed on refund) — using
// creditAmount to exclude Stripe fees. Excludes gifts, DevPass subscription
// rows, and the non-revenue end-user rows (developer margin + funded bonus).
const [revenueRow] = await db
.select({
value:
Expand All @@ -614,6 +621,7 @@ admin.openapi(getMetrics, async (c) => {
eq(tables.transaction.status, "completed"),
ne(tables.transaction.type, "credit_gift"),
notDevpassFilter,
notEndUserNonRevenueFilter,
transactionDateFilter,
),
);
Expand All @@ -632,7 +640,10 @@ admin.openapi(getMetrics, async (c) => {

// Total topped up (credits from completed credit-purchase transactions).
// Excludes DevPass virtual credits — those are granted per cycle and reset,
// so they would inflate the topped-up / unused-credits numbers.
// so they would inflate the topped-up / unused-credits numbers — and all
// end-user wallet rows, which live in their own balance economy (their spend
// is not in `totalSpent`, so counting their top-ups would inflate unused
// credits).
const [toppedUpRow] = await db
.select({
value:
Expand All @@ -645,6 +656,7 @@ admin.openapi(getMetrics, async (c) => {
and(
eq(tables.transaction.status, "completed"),
notDevpassFilter,
notEndUserWalletFilter,
transactionDateFilter,
),
);
Expand Down Expand Up @@ -702,6 +714,7 @@ admin.openapi(getMetrics, async (c) => {
eq(tables.transaction.status, "completed"),
ne(tables.transaction.type, "credit_gift"),
notDevpassFilter,
notEndUserNonRevenueFilter,
transactionDateFilter,
),
);
Expand All @@ -727,6 +740,30 @@ admin.openapi(getMetrics, async (c) => {

const totalGiftedCredits = Number(giftedRow?.value ?? 0);

// Total developer-funded end-user top-up bonus credits granted (net of
// refund claw-backs). end_user_bonus rows store creditAmount as the change to
// the developer org's credit balance — negative when a bonus is granted,
// positive when clawed back on refund — so negate the sum to report the net
// credits actually gifted into end-user wallets. Excluded from revenue above
// and surfaced here so it can be subtracted/considered in stats separately.
const [bonusRow] = await db
.select({
value:
sql<number>`COALESCE(SUM(CAST(${tables.transaction.creditAmount} AS NUMERIC)), 0)`.as(
"value",
),
})
.from(tables.transaction)
.where(
and(
eq(tables.transaction.status, "completed"),
eq(tables.transaction.type, "end_user_bonus"),
transactionDateFilter,
),
);

const totalBonusCredits = -Number(bonusRow?.value ?? 0);

// Total refunds (positive `amount` on credit_refund rows — Stripe-side refunds).
const [refundsRow] = await db
.select({
Expand Down Expand Up @@ -762,6 +799,7 @@ admin.openapi(getMetrics, async (c) => {
unusedCredits,
overage,
totalGiftedCredits,
totalBonusCredits,
totalRefunds,
});
});
Expand Down Expand Up @@ -853,6 +891,7 @@ admin.openapi(getTimeseries, async (c) => {
eq(tables.transaction.status, "completed"),
ne(tables.transaction.type, "credit_gift"),
notDevpassFilter,
notEndUserNonRevenueFilter,
gte(tables.transaction.createdAt, startDate),
lte(tables.transaction.createdAt, endDate),
),
Expand All @@ -875,6 +914,7 @@ admin.openapi(getTimeseries, async (c) => {
eq(tables.transaction.status, "completed"),
ne(tables.transaction.type, "credit_gift"),
notDevpassFilter,
notEndUserNonRevenueFilter,
gte(tables.transaction.createdAt, startDate),
lte(tables.transaction.createdAt, endDate),
),
Expand Down Expand Up @@ -917,6 +957,7 @@ admin.openapi(getTimeseries, async (c) => {
eq(tables.transaction.status, "completed"),
ne(tables.transaction.type, "credit_gift"),
notDevpassFilter,
notEndUserNonRevenueFilter,
sql`${tables.transaction.createdAt} < ${startDate}`,
),
);
Expand All @@ -935,6 +976,7 @@ admin.openapi(getTimeseries, async (c) => {
eq(tables.transaction.status, "completed"),
ne(tables.transaction.type, "credit_gift"),
notDevpassFilter,
notEndUserNonRevenueFilter,
sql`${tables.transaction.createdAt} < ${startDate}`,
),
);
Expand Down
Loading
Loading