Skip to content
20 changes: 20 additions & 0 deletions apps/api/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5822,6 +5822,16 @@ const giftCreditsRoute = createRoute({
},
description: "Credits gifted successfully.",
},
400: {
content: {
"application/json": {
schema: z.object({
message: z.string(),
}),
},
},
description: "Credits cannot be gifted to this organization kind.",
},
404: {
content: {
"application/json": {
Expand Down Expand Up @@ -5852,6 +5862,16 @@ admin.openapi(giftCreditsRoute, async (c) => {
});
}

// Real `credits` is only used by pay-as-you-go (default) orgs. chat/devpass
// orgs run purely on virtual plan credits, so gifting them real credits would
// create a balance nothing bills against — reject it here.
if (org.kind !== "default") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Allow gifts to Chat PAYG orgs

For kind: "chat" orgs this rejects the same credits balance that powers Chat pay-as-you-go: getOrCreateChatOrg documents that the Chat org owns chat plans, PAYG top-ups, and playground billing, and it migrates an existing PAYG balance into organization.credits (apps/api/src/utils/personal-org.ts:57-63, 90-99). Since the admin org detail page still exposes the Gift Credits action for these orgs, support can no longer grant or adjust Chat PAYG credits and will always get a 400; this guard should not block Chat orgs unless there is a replacement path for gifting their real balance.

Useful? React with 👍 / 👎.

throw new HTTPException(400, {
message:
"Credits can only be gifted to pay-as-you-go organizations. Chat and DevPass organizations run on virtual plan credits.",
});
}

const description = comment
? `Credits gifted by Administrator: ${comment}`
: "Credits gifted by Administrator";
Expand Down
48 changes: 5 additions & 43 deletions apps/api/src/utils/personal-org.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { db, tables, eq } from "@llmgateway/db";
import { db, tables } from "@llmgateway/db";

interface PersonalOrgUser {
id: string;
Expand Down Expand Up @@ -55,12 +55,10 @@ export async function getOrCreatePersonalOrg(user: PersonalOrgUser) {
}

// Get or create the dedicated "Chat" organization for a user. This backs
// chat.llmgateway.io (apps/playground): the chat plan, pay-as-you-go top-ups,
// and all playground billing live here, kept separate from the DevPass personal
// org used by the coding product.
//
// On first creation, any pay-as-you-go balance the user had on their personal
// org is migrated to the chat org so the playground keeps access to it.
// chat.llmgateway.io (apps/playground), kept separate from the DevPass personal
// org used by the coding product. Chat orgs run purely on virtual chat-plan
// credits and never hold a real `credits` balance — pay-as-you-go lives on
// default orgs.
Comment on lines +59 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Route PAYG media users to their funded org

When PAYG stops being copied into the Chat org, opening /image, /video, or /audio without an orgId but with credits on a default org now selects the freshly-created zero-credit Chat org first (apps/playground/src/app/image/page.tsx:92-95, same pattern in video/audio). Those clients then base the out-of-credits state on the Chat org plan status (image-page-client.tsx:787-795), so funded PAYG users are shown the plan upsell/blocked until they manually switch orgs; the chat shell has a redirect for this case, but the media pages do not.

Useful? React with 👍 / 👎.

export async function getOrCreateChatOrg(user: PersonalOrgUser) {
const userOrgs = await db.query.userOrganization.findMany({
where: {
Expand All @@ -79,14 +77,6 @@ export async function getOrCreateChatOrg(user: PersonalOrgUser) {
return existingChatOrg.organization;
}

const personalOrg = userOrgs.find(
(uo) => uo.organization?.kind === "devpass",
)?.organization;
const migratedCredits =
personalOrg && parseFloat(personalOrg.credits || "0") > 0
? personalOrg.credits
: null;

return await db.transaction(async (tx) => {
const [newOrg] = await tx
.insert(tables.organization)
Expand All @@ -95,7 +85,6 @@ export async function getOrCreateChatOrg(user: PersonalOrgUser) {
kind: "chat",
billingEmail: user.email,
retentionLevel: "none",
...(migratedCredits ? { credits: migratedCredits } : {}),
})
.returning();

Expand All @@ -111,33 +100,6 @@ export async function getOrCreateChatOrg(user: PersonalOrgUser) {
mode: "credits",
});

// One-time migration of the personal org's pay-as-you-go balance into the
// chat org. Recorded as offsetting credit_gift transactions (excluded from
// revenue analytics) so the move is auditable on both orgs.
if (migratedCredits && personalOrg) {
await tx
.update(tables.organization)
.set({ credits: "0" })
.where(eq(tables.organization.id, personalOrg.id));

await tx.insert(tables.transaction).values([
{
organizationId: personalOrg.id,
type: "credit_gift",
creditAmount: `-${migratedCredits}`,
status: "completed",
description: "Balance migrated to Chat organization",
},
{
organizationId: newOrg.id,
type: "credit_gift",
creditAmount: migratedCredits,
status: "completed",
description: "Balance migrated from personal organization",
},
]);
}

return newOrg;
});
}
272 changes: 272 additions & 0 deletions apps/worker/src/log-processing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -619,5 +619,277 @@ describe("Log Processing", () => {

expect(Number(updatedOrg!.credits)).toBe(initialCredits);
});

test("should let default org credits go negative on overage", async () => {
// Regular (kind: "default") orgs are allowed to go negative — the
// balance is reconciled on the next top-up, so flooring would lose
// genuinely incurred usage.
await db
.update(organization)
.set({ credits: "0.01", kind: "default" })
.where(eq(organization.id, testOrg.id));

await db.insert(log).values({
requestId: "test-request-default-negative",
organizationId: testOrg.id,
projectId: testProject.id,
apiKeyId: testApiKey.id,
cost: 0.05,
cached: false,
usedMode: "credits",
duration: 1000,
requestedModel: "openai/gpt-4o-mini",
requestedProvider: "openai",
usedModel: "gpt-4o-mini",
usedProvider: "openai",
responseSize: 100,
mode: "credits",
});

await batchProcessLogs();

const updatedOrg = await db.query.organization.findFirst({
where: { id: { eq: testOrg.id } },
});

expect(Number(updatedOrg!.credits)).toBeCloseTo(-0.04, 10);
});

test("should write off devpass residual when there is no credit balance", async () => {
// devpass orgs run on dev-plan virtual credits and hold no real
// balance. Usage that drains after the plan was cancelled (no active
// pool, zero credits) must be written off, never driving `credits`
// negative.
await db
.update(organization)
.set({ credits: "0.00", kind: "devpass", devPlan: "none" })
.where(eq(organization.id, testOrg.id));

await db.insert(log).values({
requestId: "test-request-devpass-writeoff",
organizationId: testOrg.id,
projectId: testProject.id,
apiKeyId: testApiKey.id,
cost: 0.05,
cached: false,
usedMode: "credits",
duration: 1000,
requestedModel: "openai/gpt-4o-mini",
requestedProvider: "openai",
usedModel: "gpt-4o-mini",
usedProvider: "openai",
responseSize: 100,
mode: "credits",
});

await batchProcessLogs();

const updatedOrg = await db.query.organization.findFirst({
where: { id: { eq: testOrg.id } },
});

// Unchanged — written off rather than charged to real credits.
expect(Number(updatedOrg!.credits)).toBe(0);
});

test("should still debit a stray real balance on a non-default org", async () => {
// chat/devpass orgs aren't supposed to hold real credits, but if a
// legacy balance exists the worker must debit it (via the credits <= 0
// guard) so it can't be spent for free, rather than writing usage off.
await db
.update(organization)
.set({ credits: "0.10", kind: "chat", chatPlan: "none" })
.where(eq(organization.id, testOrg.id));

await db.insert(log).values({
requestId: "test-request-chat-stray-balance",
organizationId: testOrg.id,
projectId: testProject.id,
apiKeyId: testApiKey.id,
cost: 0.05,
cached: false,
usedMode: "credits",
duration: 1000,
requestedModel: "openai/gpt-4o-mini",
requestedProvider: "openai",
usedModel: "gpt-4o-mini",
usedProvider: "openai",
responseSize: 100,
mode: "credits",
});

await batchProcessLogs();

const updatedOrg = await db.query.organization.findFirst({
where: { id: { eq: testOrg.id } },
});

expect(Number(updatedOrg!.credits)).toBeCloseTo(0.05, 10);
});

test("should write off chat usage when there is no real balance", async () => {
// chat orgs run on virtual chat-plan credits; `credits` is not a real
// balance for them (pay-as-you-go lives on default orgs). With no plan
// pool and no balance, usage is written off — never driving `credits`
// negative, same as devpass.
await db
.update(organization)
.set({ credits: "0.00", kind: "chat", chatPlan: "none" })
.where(eq(organization.id, testOrg.id));

await db.insert(log).values({
requestId: "test-request-chat-no-balance",
organizationId: testOrg.id,
projectId: testProject.id,
apiKeyId: testApiKey.id,
cost: 0.05,
cached: false,
usedMode: "credits",
duration: 1000,
requestedModel: "openai/gpt-4o-mini",
requestedProvider: "openai",
usedModel: "gpt-4o-mini",
usedProvider: "openai",
responseSize: 100,
mode: "credits",
});

await batchProcessLogs();

const updatedOrg = await db.query.organization.findFirst({
where: { id: { eq: testOrg.id } },
});

expect(Number(updatedOrg!.credits)).toBe(0);
});

test("should drain chat plan up to its limit and write off the overage when there is no balance", async () => {
// Active chat plan exhausted by an in-flight request, no real balance.
// The plan absorbs up to its limit; the overage is written off, not
// over-counted past the limit and not charged to `credits`.
await db
.update(organization)
.set({
credits: "0.00",
kind: "chat",
chatPlan: "starter",
chatPlanCreditsLimit: "0.02",
chatPlanCreditsUsed: "0.00",
})
.where(eq(organization.id, testOrg.id));

await db.insert(log).values({
requestId: "test-request-chat-overage-nobalance",
organizationId: testOrg.id,
projectId: testProject.id,
apiKeyId: testApiKey.id,
cost: 0.05,
cached: false,
usedMode: "credits",
duration: 1000,
requestedModel: "openai/gpt-4o-mini",
requestedProvider: "openai",
usedModel: "gpt-4o-mini",
usedProvider: "openai",
responseSize: 100,
mode: "credits",
});

await batchProcessLogs();

const updatedOrg = await db.query.organization.findFirst({
where: { id: { eq: testOrg.id } },
});

expect(Number(updatedOrg!.credits)).toBe(0);
expect(Number(updatedOrg!.chatPlanCreditsUsed)).toBeCloseTo(0.02, 10);
});

test("should drain dev plan up to its limit and write off the overage when there is no balance", async () => {
// Active dev plan whose cycle allowance is exhausted by an in-flight
// request, with no real credits. The plan absorbs up to its limit;
// the overage is written off — never charged to real credits, and not
// over-counted past the plan limit.
await db
.update(organization)
.set({
credits: "0.00",
kind: "devpass",
devPlan: "pro",
devPlanCreditsLimit: "0.02",
devPlanCreditsUsed: "0.00",
})
.where(eq(organization.id, testOrg.id));

await db.insert(log).values({
requestId: "test-request-dev-overage-nobalance",
organizationId: testOrg.id,
projectId: testProject.id,
apiKeyId: testApiKey.id,
cost: 0.05,
cached: false,
usedMode: "credits",
duration: 1000,
requestedModel: "openai/gpt-4o-mini",
requestedProvider: "openai",
usedModel: "gpt-4o-mini",
usedProvider: "openai",
responseSize: 100,
mode: "credits",
});

await batchProcessLogs();

const updatedOrg = await db.query.organization.findFirst({
where: { id: { eq: testOrg.id } },
});

expect(Number(updatedOrg!.credits)).toBe(0);
// Drained up to the limit only; the $0.03 overage is written off.
expect(Number(updatedOrg!.devPlanCreditsUsed)).toBeCloseTo(0.02, 10);
});

test("should debit a stray balance for plan overage instead of over-counting virtual credits", async () => {
// Plan exhausted on a non-default org that holds a stray real balance.
// The overage must come out of real credits, NOT push the plan's
// virtual counter past its limit (which would spend the balance
// silently and reset at renewal).
await db
.update(organization)
.set({
credits: "1.00",
kind: "chat",
chatPlan: "starter",
chatPlanCreditsLimit: "0.02",
chatPlanCreditsUsed: "0.00",
})
.where(eq(organization.id, testOrg.id));

await db.insert(log).values({
requestId: "test-request-chat-overage-balance",
organizationId: testOrg.id,
projectId: testProject.id,
apiKeyId: testApiKey.id,
cost: 0.05,
cached: false,
usedMode: "credits",
duration: 1000,
requestedModel: "openai/gpt-4o-mini",
requestedProvider: "openai",
usedModel: "gpt-4o-mini",
usedProvider: "openai",
responseSize: 100,
mode: "credits",
});

await batchProcessLogs();

const updatedOrg = await db.query.organization.findFirst({
where: { id: { eq: testOrg.id } },
});

expect(Number(updatedOrg!.chatPlanCreditsUsed)).toBeCloseTo(0.02, 10);
expect(Number(updatedOrg!.credits)).toBeCloseTo(0.97, 10);
});
});
});
Loading
Loading