Skip to content

Commit 0116fa3

Browse files
Count the whole ledger, and only the sessions that are still live
Three admin numbers were reporting something other than what their labels claimed. The Event Ledger's stat row was computed from the page of entries the query had just capped, so "Total" was always the row limit and the status cards only described the newest page. It now counts with a GROUP BY over every row matching the same filter, and the panel footer says how much of the ledger is on screen. "Active Sessions" on the dashboard counted every row in the session table, expired ones included. It now filters on expiresAt. getAdminSubscriptionStats scans Stripe 100 subscriptions at a time and computed a hasMore flag that nothing read, so a platform with more than 100 in any status silently showed 100. The flag is now named truncated, carries the scan limit, and the Subscriptions panel says the counts are floors when it trips. Also drops stats.byType from the ledger response, which nothing read. Claude-Session: https://claude.ai/code/session_01LqxkXwhjRDsJ1N9cBYpU1n
1 parent beab911 commit 0116fa3

7 files changed

Lines changed: 103 additions & 43 deletions

File tree

packages/web/src/components/admin/AnalyticsSection.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,11 @@ export function AnalyticsSection() {
299299
<div className='grid grid-cols-1 gap-6 lg:grid-cols-3'>
300300
<AdminPanel
301301
title='Subscriptions'
302+
description={
303+
subscriptionData?.truncated ?
304+
`At least ${subscriptionData.statusScanLimit} in one status - counts are floors`
305+
: undefined
306+
}
302307
padded
303308
action={
304309
<RefreshButton

packages/web/src/routes/_app/_protected/admin/billing.ledger.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,13 @@ function AdminBillingLedgerPage() {
312312

313313
<AdminPanel
314314
title='Events'
315+
footer={
316+
stats && entries.length < stats.total ?
317+
<span className='text-muted-foreground text-[13px]'>
318+
Showing the {entries.length} most recent of {stats.total}.
319+
</span>
320+
: undefined
321+
}
315322
action={
316323
<>
317324
<Input

packages/web/src/server/functions/__tests__/admin-billing-observability.server.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,48 @@ describe('getAdminBillingLedger', () => {
228228
result.entries.forEach(e => expect(e.status).toBe('failed'));
229229
});
230230

231+
it('counts every matching row, not just the page it returns', async () => {
232+
const nowSec = Math.floor(Date.now() / 1000);
233+
for (let i = 0; i < 5; i++) {
234+
await seedStripeEventLedger({
235+
id: `lt${i}`,
236+
payloadHash: `ht${i}`,
237+
receivedAt: nowSec + i,
238+
route: '/webhooks/stripe',
239+
requestId: `rt${i}`,
240+
status: i < 3 ? 'processed' : 'failed',
241+
});
242+
}
243+
244+
const result = await getAdminBillingLedger(mockAdminSession(), createDb(env.DB), { limit: 2 });
245+
expect(result.entries.length).toBe(2);
246+
expect(result.stats.total).toBe(5);
247+
expect(result.stats.byStatus.processed).toBe(3);
248+
expect(result.stats.byStatus.failed).toBe(2);
249+
});
250+
251+
it('narrows the stats to the active filter', async () => {
252+
const nowSec = Math.floor(Date.now() / 1000);
253+
for (let i = 0; i < 5; i++) {
254+
await seedStripeEventLedger({
255+
id: `lf${i}`,
256+
payloadHash: `hf${i}`,
257+
receivedAt: nowSec + i,
258+
route: '/webhooks/stripe',
259+
requestId: `rf${i}`,
260+
status: i < 3 ? 'processed' : 'failed',
261+
});
262+
}
263+
264+
const result = await getAdminBillingLedger(mockAdminSession(), createDb(env.DB), {
265+
status: 'failed',
266+
limit: 1,
267+
});
268+
expect(result.entries.length).toBe(1);
269+
expect(result.stats.total).toBe(2);
270+
expect(result.stats.byStatus).toEqual({ failed: 2 });
271+
});
272+
231273
it('filters by type', async () => {
232274
const nowSec = Math.floor(Date.now() / 1000);
233275
await seedStripeEventLedger({

packages/web/src/server/functions/__tests__/admin-stats.server.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ describe('getAdminSubscriptionStats', () => {
169169
expect(result.trialing).toBe(1);
170170
expect(result.pastDue).toBe(0);
171171
expect(result.canceled).toBe(2);
172-
expect(result.hasMore).toBe(true);
172+
expect(result.truncated).toBe(true);
173173
});
174174

175175
it('throws when Stripe throws', async () => {

packages/web/src/server/functions/__tests__/admin-users.server.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,18 @@ function mockAdminSession(overrides?: { userId?: string }): Session {
6464
} as Session;
6565
}
6666

67-
async function seedSessionRow(id: string, userId: string, opts: Partial<{ ip: string }> = {}) {
67+
async function seedSessionRow(
68+
id: string,
69+
userId: string,
70+
opts: Partial<{ ip: string; expiresAt: Date }> = {},
71+
) {
6872
const db = createDb(env.DB);
6973
const now = new Date();
7074
await db.insert(session).values({
7175
id,
7276
token: `${id}-token`,
7377
userId,
74-
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
78+
expiresAt: opts.expiresAt ?? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
7579
createdAt: now,
7680
updatedAt: now,
7781
ipAddress: opts.ip ?? null,
@@ -111,6 +115,15 @@ describe('getAdminStats', () => {
111115
expect(result.recentSignups).toBeGreaterThanOrEqual(2);
112116
void admin;
113117
});
118+
119+
it('counts only sessions that have not expired', async () => {
120+
const u = await buildUser();
121+
await seedSessionRow('s-live', u.id);
122+
await seedSessionRow('s-expired', u.id, { expiresAt: new Date(Date.now() - 60_000) });
123+
124+
const result = await getAdminStats(mockAdminSession(), createDb(env.DB));
125+
expect(result.activeSessions).toBe(1);
126+
});
114127
});
115128

116129
describe('GET /api/admin/users', () => {

packages/web/src/server/functions/admin-billing.server.ts

Lines changed: 20 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Database } from '@corates/db/client';
22
import { stripeEventLedger, subscription } from '@corates/db/schema';
3-
import { and, desc, eq } from 'drizzle-orm';
3+
import { and, count, desc, eq } from 'drizzle-orm';
44
import { throwDomainError, AUTH_ERRORS } from '@corates/shared';
55
import { isAdminUser } from '@corates/workers/auth-admin';
66
import { LedgerStatus } from '@corates/db/stripe-event-ledger';
@@ -26,41 +26,28 @@ export async function getAdminBillingLedger(
2626
const conditions = [];
2727
if (status) conditions.push(eq(stripeEventLedger.status, status));
2828
if (eventType) conditions.push(eq(stripeEventLedger.type, eventType));
29+
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
2930

30-
const entries =
31-
conditions.length > 0 ?
32-
await db
33-
.select()
34-
.from(stripeEventLedger)
35-
.where(and(...conditions))
36-
.orderBy(desc(stripeEventLedger.receivedAt))
37-
.limit(limit)
38-
.all()
39-
: await db
40-
.select()
41-
.from(stripeEventLedger)
42-
.orderBy(desc(stripeEventLedger.receivedAt))
43-
.limit(limit)
44-
.all();
31+
const entries = await db
32+
.select()
33+
.from(stripeEventLedger)
34+
.where(whereClause)
35+
.orderBy(desc(stripeEventLedger.receivedAt))
36+
.limit(limit)
37+
.all();
38+
39+
// Counted over every matching row rather than the page above, so the totals
40+
// are not just the page size.
41+
const statusCounts = await db
42+
.select({ status: stripeEventLedger.status, count: count() })
43+
.from(stripeEventLedger)
44+
.where(whereClause)
45+
.groupBy(stripeEventLedger.status)
46+
.all();
4547

4648
const stats = {
47-
total: entries.length,
48-
byStatus: entries.reduce(
49-
(acc, e) => {
50-
acc[e.status] = (acc[e.status] || 0) + 1;
51-
return acc;
52-
},
53-
{} as Record<string, number>,
54-
),
55-
byType: entries
56-
.filter(e => e.type)
57-
.reduce(
58-
(acc, e) => {
59-
if (e.type) acc[e.type] = (acc[e.type] || 0) + 1;
60-
return acc;
61-
},
62-
{} as Record<string, number>,
63-
),
49+
total: statusCounts.reduce((sum, row) => sum + row.count, 0),
50+
byStatus: Object.fromEntries(statusCounts.map(row => [row.status, row.count])),
6451
};
6552

6653
return {

packages/web/src/server/functions/admin-stats.server.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
organization,
88
stripeEventLedger,
99
} from '@corates/db/schema';
10-
import { count, gte, sql } from 'drizzle-orm';
10+
import { count, gt, gte, sql } from 'drizzle-orm';
1111
import { throwDomainError, AUTH_ERRORS } from '@corates/shared';
1212
import { isAdminUser } from '@corates/workers/auth-admin';
1313
import { TIME_DURATIONS } from '@corates/workers/constants';
@@ -27,7 +27,7 @@ export async function getAdminStats(session: Session, db: Database) {
2727
const [userCount, projectCount, sessionCount] = await Promise.all([
2828
db.select({ count: count() }).from(user),
2929
db.select({ count: count() }).from(projects),
30-
db.select({ count: count() }).from(sessionTable),
30+
db.select({ count: count() }).from(sessionTable).where(gt(sessionTable.expiresAt, new Date())),
3131
]);
3232

3333
const sevenDaysAgo = Math.floor(Date.now() / 1000) - TIME_DURATIONS.STATS_RECENT_DAYS_SEC;
@@ -195,23 +195,29 @@ export async function getAdminWebhookStats(
195195
};
196196
}
197197

198+
const SUBSCRIPTION_STATUS_SCAN_LIMIT = 100;
199+
198200
export async function getAdminSubscriptionStats(session: Session) {
199201
assertAdmin(session);
200202

201203
const stripe = createStripeClient(env.STRIPE_SECRET_KEY);
204+
const limit = SUBSCRIPTION_STATUS_SCAN_LIMIT;
202205
const statusCounts = await Promise.all([
203-
stripe.subscriptions.search({ query: 'status:"active"', limit: 100 }),
204-
stripe.subscriptions.search({ query: 'status:"trialing"', limit: 100 }),
205-
stripe.subscriptions.search({ query: 'status:"past_due"', limit: 100 }),
206-
stripe.subscriptions.search({ query: 'status:"canceled"', limit: 100 }),
206+
stripe.subscriptions.search({ query: 'status:"active"', limit }),
207+
stripe.subscriptions.search({ query: 'status:"trialing"', limit }),
208+
stripe.subscriptions.search({ query: 'status:"past_due"', limit }),
209+
stripe.subscriptions.search({ query: 'status:"canceled"', limit }),
207210
]);
208211

209212
return {
210213
active: statusCounts[0].data.length,
211214
trialing: statusCounts[1].data.length,
212215
pastDue: statusCounts[2].data.length,
213216
canceled: statusCounts[3].data.length,
214-
hasMore: statusCounts.some(r => r.has_more),
217+
// Stripe has no count API, so each status is a capped scan; once one fills
218+
// its page every count here is a floor rather than a total.
219+
truncated: statusCounts.some(r => r.has_more),
220+
statusScanLimit: SUBSCRIPTION_STATUS_SCAN_LIMIT,
215221
};
216222
}
217223

0 commit comments

Comments
 (0)