cpns / packages /api /src /lib /credit.ts
pramudya-yb
Rename project from Labas to Pram
2b62b0c
Raw
History Blame Contribute Delete
5.9 kB
import { eq, sql, and, desc, sum, gte } from "drizzle-orm";
import { db } from "@pram/db";
import { userCredit, creditTransaction, user, platformConfig } from "@pram/db";
import { TRPCError } from "@trpc/server";
import { env } from "@pram/env/server";
const COOLDOWN_DAYS = 7;
async function getConfigRaw(key: string): Promise<string | null> {
const [row] = await db
.select({ value: platformConfig.value })
.from(platformConfig)
.where(eq(platformConfig.key, key))
.limit(1);
return row?.value ?? null;
}
export async function getConfig(key: string, envFallback: () => string): Promise<string> {
const dbVal = await getConfigRaw(key);
return dbVal ?? envFallback();
}
export async function setConfig(key: string, value: string): Promise<void> {
await db
.insert(platformConfig)
.values({ key, value })
.onConflictDoUpdate({
target: [platformConfig.key],
set: { value },
});
}
async function isFreeCreditsEnabled(): Promise<boolean> {
const val = await getConfig("free_credits_enabled", () => String(env.FREE_CREDITS_ENABLED));
return val === "true";
}
async function getFreeCreditsMaxPool(): Promise<number> {
const val = await getConfig("free_credits_max_pool", () => String(env.FREE_CREDITS_MAX_POOL));
return parseInt(val, 10) || env.FREE_CREDITS_MAX_POOL;
}
export async function getUserCredit(userId: string) {
const [credit] = await db
.select()
.from(userCredit)
.where(eq(userCredit.userId, userId))
.limit(1);
return credit ?? { userId, tokenBalance: 0, lifetimeTokensUsed: 0 };
}
export async function ensureUserCreditRow(userId: string) {
await db
.insert(userCredit)
.values({ userId, tokenBalance: 0 })
.onConflictDoNothing();
}
export async function deductCredit(
userId: string,
tokens: number,
): Promise<{ newBalance: number }> {
await ensureUserCreditRow(userId);
const [current] = await db
.select({ balance: userCredit.tokenBalance })
.from(userCredit)
.where(eq(userCredit.userId, userId))
.limit(1);
if (!current) {
throw new TRPCError({ code: "BAD_REQUEST", message: "No credits available" });
}
if (current.balance < tokens) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Insufficient credits" });
}
const [updated] = await db
.update(userCredit)
.set({
tokenBalance: sql`token_balance - ${tokens}`,
lifetimeTokensUsed: sql`lifetime_tokens_used + ${tokens}`,
})
.where(eq(userCredit.userId, userId))
.returning();
if (!updated) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Failed to deduct credits" });
}
await db.insert(creditTransaction).values({
userId,
amount: -tokens,
type: "generation_spend",
tokensUsed: tokens,
});
return { newBalance: updated.tokenBalance };
}
export async function isUserSuspended(userId: string): Promise<boolean> {
const [u] = await db
.select({ suspended: user.suspended })
.from(user)
.where(eq(user.id, userId))
.limit(1);
return u?.suspended ?? false;
}
export async function grantSignupCredit(
userId: string,
amount: number,
): Promise<void> {
await ensureUserCreditRow(userId);
await db
.update(userCredit)
.set({
tokenBalance: sql`token_balance + ${amount}`,
})
.where(eq(userCredit.userId, userId));
await db.insert(creditTransaction).values({
userId,
amount,
type: "signup_bonus",
description: "Welcome credits",
});
}
export async function getPoolUsage() {
const maxPool = await getFreeCreditsMaxPool();
const [agg] = await db
.select({ totalDistributed: sum(creditTransaction.amount) })
.from(creditTransaction)
.where(gte(creditTransaction.amount, 0));
const totalDistributed = Number(agg?.totalDistributed ?? 0);
return {
totalDistributed,
maxPool,
remaining: Math.max(0, maxPool - totalDistributed),
};
}
export async function getLastRefillAt(userId: string) {
const [txn] = await db
.select({ createdAt: creditTransaction.createdAt })
.from(creditTransaction)
.where(
and(
eq(creditTransaction.userId, userId),
gte(creditTransaction.amount, 0),
),
)
.orderBy(desc(creditTransaction.createdAt))
.limit(1);
return txn ?? null;
}
function calcCooldownRemaining(lastRefillAt: Date | string | null): number {
if (!lastRefillAt) return 0;
const last = new Date(lastRefillAt).getTime();
const cooldownEnd = last + COOLDOWN_DAYS * 24 * 60 * 60 * 1000;
const remaining = Math.max(0, Math.ceil((cooldownEnd - Date.now()) / (24 * 60 * 60 * 1000)));
return remaining;
}
export async function autoRefillIfEligible(
userId: string,
): Promise<{ refilled: boolean; message: string; newBalance?: number }> {
if (!(await isFreeCreditsEnabled())) {
return { refilled: false, message: "Free credits are currently disabled." };
}
const pool = await getPoolUsage();
const amount = env.DEFAULT_SIGNUP_CREDIT_TOKENS;
if (pool.remaining < amount) {
return { refilled: false, message: "Free credit pool is exhausted. Contact admin." };
}
const lastRefill = await getLastRefillAt(userId);
const cooldown = calcCooldownRemaining(lastRefill?.createdAt ?? null);
if (cooldown > 0) {
return { refilled: false, message: `Cooldown active. Try again in ${cooldown} day(s).` };
}
await ensureUserCreditRow(userId);
await db
.update(userCredit)
.set({
tokenBalance: sql`token_balance + ${amount}`,
})
.where(eq(userCredit.userId, userId));
await db.insert(creditTransaction).values({
userId,
amount,
type: "auto_refill",
description: "Auto-refill from free credit pool",
});
const credit = await getUserCredit(userId);
return { refilled: true, message: `Refilled ${amount.toLocaleString()} tokens from free pool.`, newBalance: credit.tokenBalance };
}