Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
91 changes: 91 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,91 @@
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",
},
// 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("excludes SDK end-user rows from revenue and 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;

// Only the credit_topup counts as revenue — gift, bonus, and margin rows
// are all excluded (previously the -5 bonus would have deflated this to 15).
expect(body.totalRevenue).toBe(20);
// Processed excludes the same rows (gross Stripe on credit purchases only).
expect(body.totalProcessed).toBe(21);
// Topped up = purchases + gifts (23), with bonus/margin excluded.
expect(body.totalToppedUp).toBe(23);
// Bonus credits granted, reported as a positive figure.
expect(body.totalBonusCredits).toBe(5);
expect(body.totalGiftedCredits).toBe(3);
});
});
38 changes: 37 additions & 1 deletion apps/api/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ 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,
notEndUserWalletFilter,
} from "@/utils/devpass-filter.js";
import {
HOURLY_BUCKET_THRESHOLD_MINUTES,
floorToHourStart,
Expand Down Expand Up @@ -89,6 +92,7 @@ const adminMetricsSchema = z.object({
unusedCredits: z.number(),
overage: z.number(),
totalGiftedCredits: z.number(),
totalBonusCredits: z.number(),
totalRefunds: z.number(),
});

Expand Down Expand Up @@ -614,6 +618,7 @@ admin.openapi(getMetrics, async (c) => {
eq(tables.transaction.status, "completed"),
ne(tables.transaction.type, "credit_gift"),
notDevpassFilter,
notEndUserWalletFilter,
transactionDateFilter,
),
);
Expand Down Expand Up @@ -645,6 +650,7 @@ admin.openapi(getMetrics, async (c) => {
and(
eq(tables.transaction.status, "completed"),
notDevpassFilter,
notEndUserWalletFilter,
transactionDateFilter,
),
);
Expand Down Expand Up @@ -702,6 +708,7 @@ admin.openapi(getMetrics, async (c) => {
eq(tables.transaction.status, "completed"),
ne(tables.transaction.type, "credit_gift"),
notDevpassFilter,
notEndUserWalletFilter,
transactionDateFilter,
),
);
Expand All @@ -727,6 +734,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 +793,7 @@ admin.openapi(getMetrics, async (c) => {
unusedCredits,
overage,
totalGiftedCredits,
totalBonusCredits,
totalRefunds,
});
});
Expand Down Expand Up @@ -853,6 +885,7 @@ admin.openapi(getTimeseries, async (c) => {
eq(tables.transaction.status, "completed"),
ne(tables.transaction.type, "credit_gift"),
notDevpassFilter,
notEndUserWalletFilter,
gte(tables.transaction.createdAt, startDate),
lte(tables.transaction.createdAt, endDate),
),
Expand All @@ -875,6 +908,7 @@ admin.openapi(getTimeseries, async (c) => {
eq(tables.transaction.status, "completed"),
ne(tables.transaction.type, "credit_gift"),
notDevpassFilter,
notEndUserWalletFilter,
gte(tables.transaction.createdAt, startDate),
lte(tables.transaction.createdAt, endDate),
),
Expand Down Expand Up @@ -917,6 +951,7 @@ admin.openapi(getTimeseries, async (c) => {
eq(tables.transaction.status, "completed"),
ne(tables.transaction.type, "credit_gift"),
notDevpassFilter,
notEndUserWalletFilter,
sql`${tables.transaction.createdAt} < ${startDate}`,
),
);
Expand All @@ -935,6 +970,7 @@ admin.openapi(getTimeseries, async (c) => {
eq(tables.transaction.status, "completed"),
ne(tables.transaction.type, "credit_gift"),
notDevpassFilter,
notEndUserWalletFilter,
sql`${tables.transaction.createdAt} < ${startDate}`,
),
);
Expand Down
Loading
Loading