Skip to content

Commit 17841af

Browse files
committed
feat: new admin panel
1 parent 7c436a8 commit 17841af

17 files changed

Lines changed: 2245 additions & 237 deletions

‎apps/api/src/lib/ai-credits/polar-meter.test.ts‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { beforeEach, describe, expect, it, mock } from "bun:test";
2-
import { getAiCreditMeterState, ingestAiCreditUsage } from "./polar-meter";
2+
import {
3+
getAiCreditMeterState,
4+
grantAiCreditUsage,
5+
ingestAiCreditUsage,
6+
} from "./polar-meter";
37

48
class FakeRedis {
59
store = new Map<string, string>();
@@ -232,6 +236,60 @@ describe("ai credit Polar meter gateway", () => {
232236
expect(updatedCache?.consumedUnits).toBe(12.5);
233237
});
234238

239+
it("ingests negative credits and optimistically updates cached balance after admin grant", async () => {
240+
await redis.set(
241+
"ai-credit:meter:org-1",
242+
buildCachedMeterState({
243+
organizationId: "org-1",
244+
lastSyncedAt: new Date(nowMs - 5000).toISOString(),
245+
balance: 10,
246+
})
247+
);
248+
249+
const grantResult = await grantAiCreditUsage(
250+
{
251+
organizationId: "org-1",
252+
websiteId: "site-1",
253+
amount: 7.5,
254+
adminUserId: "admin-1",
255+
},
256+
{
257+
deps: {
258+
redis,
259+
polar,
260+
now: () => nowMs,
261+
meterId: "meter-ai-1",
262+
eventName: "ai_usage",
263+
cacheTtlSeconds: 15,
264+
staleTtlSeconds: 300,
265+
},
266+
}
267+
);
268+
269+
expect(grantResult.status).toBe("ingested");
270+
expect(polar.events.ingest).toHaveBeenCalledWith({
271+
events: [
272+
{
273+
name: "ai_usage",
274+
externalCustomerId: "org-1",
275+
metadata: {
276+
credits: -7.5,
277+
websiteId: "site-1",
278+
organizationId: "org-1",
279+
adminUserId: "admin-1",
280+
kind: "admin_grant",
281+
},
282+
},
283+
],
284+
});
285+
286+
const updatedCacheRaw = await redis.get("ai-credit:meter:org-1");
287+
const updatedCache = updatedCacheRaw ? JSON.parse(updatedCacheRaw) : null;
288+
expect(updatedCache?.balance).toBe(17.5);
289+
expect(updatedCache?.creditedUnits).toBe(42.5);
290+
expect(updatedCache?.consumedUnits).toBe(10);
291+
});
292+
235293
it("skips ingest while backoff cooldown is active", async () => {
236294
await redis.set("ai-credit:ingest-backoff:org-1", "active-backoff");
237295

‎apps/api/src/lib/ai-credits/polar-meter.ts‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,33 @@ export type IngestAiCreditUsageInput = {
4848
totalToolCount: number;
4949
};
5050

51+
export type GrantAiCreditUsageInput = {
52+
organizationId: string;
53+
websiteId: string;
54+
amount: number;
55+
adminUserId: string;
56+
};
57+
5158
export type IngestAiCreditUsageStatus =
5259
| "ingested"
5360
| "failed"
5461
| "skipped_backoff"
5562
| "skipped_zero"
5663
| "skipped_disabled";
5764

65+
export type GrantAiCreditUsageStatus =
66+
| "ingested"
67+
| "failed"
68+
| "skipped_disabled";
69+
5870
export type IngestAiCreditUsageResult = {
5971
status: IngestAiCreditUsageStatus;
6072
};
6173

74+
export type GrantAiCreditUsageResult = {
75+
status: GrantAiCreditUsageStatus;
76+
};
77+
6278
type EventMetadataValue = string | number | boolean;
6379

6480
type RedisLike = {
@@ -300,6 +316,28 @@ function optimisticConsumeCredits(params: {
300316
};
301317
}
302318

319+
function optimisticGrantCredits(params: {
320+
entry: CachedMeterEntry;
321+
amount: number;
322+
nowMs: number;
323+
}): CachedMeterEntry {
324+
const nextBalance =
325+
typeof params.entry.balance === "number"
326+
? params.entry.balance + params.amount
327+
: null;
328+
const nextCredited =
329+
typeof params.entry.creditedUnits === "number"
330+
? params.entry.creditedUnits + params.amount
331+
: null;
332+
333+
return {
334+
...params.entry,
335+
balance: nextBalance,
336+
creditedUnits: nextCredited,
337+
lastSyncedAt: toTimestamp(params.nowMs),
338+
};
339+
}
340+
303341
export async function getAiCreditMeterState(
304342
organizationId: string,
305343
options?: GatewayOptions
@@ -572,3 +610,70 @@ export async function ingestAiCreditUsage(
572610

573611
return { status: "ingested" };
574612
}
613+
614+
export async function grantAiCreditUsage(
615+
input: GrantAiCreditUsageInput,
616+
options?: GatewayOptions
617+
): Promise<GrantAiCreditUsageResult> {
618+
const deps = resolveDeps(options?.deps);
619+
620+
if (!deps.billingEnabled) {
621+
return { status: "skipped_disabled" };
622+
}
623+
624+
try {
625+
await deps.polar.events.ingest({
626+
events: [
627+
{
628+
name: deps.eventName,
629+
externalCustomerId: input.organizationId,
630+
metadata: {
631+
credits: -input.amount,
632+
websiteId: input.websiteId,
633+
organizationId: input.organizationId,
634+
adminUserId: input.adminUserId,
635+
kind: "admin_grant",
636+
},
637+
},
638+
],
639+
});
640+
} catch (error) {
641+
console.error(
642+
`[ai-credits] Failed to grant usage credits for org=${input.organizationId}:`,
643+
error
644+
);
645+
return { status: "failed" };
646+
}
647+
648+
const cacheKey = buildMeterCacheKey(input.organizationId);
649+
let rawCache: string | null = null;
650+
try {
651+
rawCache = await deps.redis.get(cacheKey);
652+
} catch {
653+
return { status: "ingested" };
654+
}
655+
const cached = parseCachedEntry(rawCache);
656+
657+
if (!cached?.meterBacked) {
658+
return { status: "ingested" };
659+
}
660+
661+
const updated = optimisticGrantCredits({
662+
entry: cached,
663+
amount: input.amount,
664+
nowMs: deps.now(),
665+
});
666+
667+
try {
668+
await writeCachedEntry({
669+
redis: deps.redis,
670+
key: cacheKey,
671+
entry: updated,
672+
staleTtlSeconds: deps.staleTtlSeconds,
673+
});
674+
} catch {
675+
// Cache update is best-effort only.
676+
}
677+
678+
return { status: "ingested" };
679+
}

0 commit comments

Comments
 (0)