@@ -443,13 +403,13 @@ function StripeToolsPage() {
)}
- {formatDateTime(sub.currentPeriodStart)} -{' '}
- {formatDateTime(sub.currentPeriodEnd)}
+ {formatDateTime(sub.items[0]?.currentPeriodStart)} -{' '}
+ {formatDateTime(sub.items[0]?.currentPeriodEnd)}
{sub.trialEnd &&
sub.status === 'trialing' &&
` - trial ends ${formatDateTime(sub.trialEnd)}`}
- {sub.items && sub.items.length > 0 && (
+ {sub.items.length > 0 && (
{sub.items
.map(
@@ -476,8 +436,10 @@ function StripeToolsPage() {
)}
{invoices && (
-
- {invoices.length === 0 ?
+
+ {invoices.rows.length === 0 ?
:
@@ -490,13 +452,15 @@ function StripeToolsPage() {
- {invoices.map(invoice => (
+ {invoices.rows.map(invoice => (
{invoice.number || invoice.id}
- {invoice.status}
+
+ {invoice.status ?? 'unknown'}
+
{formatCurrency(invoice.total, invoice.currency)}
diff --git a/packages/web/src/server/functions/__tests__/admin-billing-observability.server.test.ts b/packages/web/src/server/functions/__tests__/admin-billing-observability.server.test.ts
index e0ab13358..4ee8cc4dd 100644
--- a/packages/web/src/server/functions/__tests__/admin-billing-observability.server.test.ts
+++ b/packages/web/src/server/functions/__tests__/admin-billing-observability.server.test.ts
@@ -228,6 +228,48 @@ describe('getAdminBillingLedger', () => {
result.entries.forEach(e => expect(e.status).toBe('failed'));
});
+ it('counts every matching row, not just the page it returns', async () => {
+ const nowSec = Math.floor(Date.now() / 1000);
+ for (let i = 0; i < 5; i++) {
+ await seedStripeEventLedger({
+ id: `lt${i}`,
+ payloadHash: `ht${i}`,
+ receivedAt: nowSec + i,
+ route: '/webhooks/stripe',
+ requestId: `rt${i}`,
+ status: i < 3 ? 'processed' : 'failed',
+ });
+ }
+
+ const result = await getAdminBillingLedger(mockAdminSession(), createDb(env.DB), { limit: 2 });
+ expect(result.entries.length).toBe(2);
+ expect(result.stats.total).toBe(5);
+ expect(result.stats.byStatus.processed).toBe(3);
+ expect(result.stats.byStatus.failed).toBe(2);
+ });
+
+ it('narrows the stats to the active filter', async () => {
+ const nowSec = Math.floor(Date.now() / 1000);
+ for (let i = 0; i < 5; i++) {
+ await seedStripeEventLedger({
+ id: `lf${i}`,
+ payloadHash: `hf${i}`,
+ receivedAt: nowSec + i,
+ route: '/webhooks/stripe',
+ requestId: `rf${i}`,
+ status: i < 3 ? 'processed' : 'failed',
+ });
+ }
+
+ const result = await getAdminBillingLedger(mockAdminSession(), createDb(env.DB), {
+ status: 'failed',
+ limit: 1,
+ });
+ expect(result.entries.length).toBe(1);
+ expect(result.stats.total).toBe(2);
+ expect(result.stats.byStatus).toEqual({ failed: 2 });
+ });
+
it('filters by type', async () => {
const nowSec = Math.floor(Date.now() / 1000);
await seedStripeEventLedger({
diff --git a/packages/web/src/server/functions/__tests__/admin-stats.server.test.ts b/packages/web/src/server/functions/__tests__/admin-stats.server.test.ts
index c0159cb8a..281cdba3d 100644
--- a/packages/web/src/server/functions/__tests__/admin-stats.server.test.ts
+++ b/packages/web/src/server/functions/__tests__/admin-stats.server.test.ts
@@ -169,7 +169,7 @@ describe('getAdminSubscriptionStats', () => {
expect(result.trialing).toBe(1);
expect(result.pastDue).toBe(0);
expect(result.canceled).toBe(2);
- expect(result.hasMore).toBe(true);
+ expect(result.truncated).toBe(true);
});
it('throws when Stripe throws', async () => {
diff --git a/packages/web/src/server/functions/__tests__/admin-users.server.test.ts b/packages/web/src/server/functions/__tests__/admin-users.server.test.ts
index 395187a97..7cb6e296c 100644
--- a/packages/web/src/server/functions/__tests__/admin-users.server.test.ts
+++ b/packages/web/src/server/functions/__tests__/admin-users.server.test.ts
@@ -64,14 +64,18 @@ function mockAdminSession(overrides?: { userId?: string }): Session {
} as Session;
}
-async function seedSessionRow(id: string, userId: string, opts: Partial<{ ip: string }> = {}) {
+async function seedSessionRow(
+ id: string,
+ userId: string,
+ opts: Partial<{ ip: string; expiresAt: Date }> = {},
+) {
const db = createDb(env.DB);
const now = new Date();
await db.insert(session).values({
id,
token: `${id}-token`,
userId,
- expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
+ expiresAt: opts.expiresAt ?? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
createdAt: now,
updatedAt: now,
ipAddress: opts.ip ?? null,
@@ -111,6 +115,15 @@ describe('getAdminStats', () => {
expect(result.recentSignups).toBeGreaterThanOrEqual(2);
void admin;
});
+
+ it('counts only sessions that have not expired', async () => {
+ const u = await buildUser();
+ await seedSessionRow('s-live', u.id);
+ await seedSessionRow('s-expired', u.id, { expiresAt: new Date(Date.now() - 60_000) });
+
+ const result = await getAdminStats(mockAdminSession(), createDb(env.DB));
+ expect(result.activeSessions).toBe(1);
+ });
});
describe('GET /api/admin/users', () => {
diff --git a/packages/web/src/server/functions/admin-billing.server.ts b/packages/web/src/server/functions/admin-billing.server.ts
index 2e31cc2d4..0fabdc877 100644
--- a/packages/web/src/server/functions/admin-billing.server.ts
+++ b/packages/web/src/server/functions/admin-billing.server.ts
@@ -1,6 +1,6 @@
import type { Database } from '@corates/db/client';
import { stripeEventLedger, subscription } from '@corates/db/schema';
-import { and, desc, eq } from 'drizzle-orm';
+import { and, count, desc, eq } from 'drizzle-orm';
import { throwDomainError, AUTH_ERRORS } from '@corates/shared';
import { isAdminUser } from '@corates/workers/auth-admin';
import { LedgerStatus } from '@corates/db/stripe-event-ledger';
@@ -26,41 +26,28 @@ export async function getAdminBillingLedger(
const conditions = [];
if (status) conditions.push(eq(stripeEventLedger.status, status));
if (eventType) conditions.push(eq(stripeEventLedger.type, eventType));
+ const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
- const entries =
- conditions.length > 0 ?
- await db
- .select()
- .from(stripeEventLedger)
- .where(and(...conditions))
- .orderBy(desc(stripeEventLedger.receivedAt))
- .limit(limit)
- .all()
- : await db
- .select()
- .from(stripeEventLedger)
- .orderBy(desc(stripeEventLedger.receivedAt))
- .limit(limit)
- .all();
+ const entries = await db
+ .select()
+ .from(stripeEventLedger)
+ .where(whereClause)
+ .orderBy(desc(stripeEventLedger.receivedAt))
+ .limit(limit)
+ .all();
+
+ // Counted over every matching row rather than the page above, so the totals
+ // are not just the page size.
+ const statusCounts = await db
+ .select({ status: stripeEventLedger.status, count: count() })
+ .from(stripeEventLedger)
+ .where(whereClause)
+ .groupBy(stripeEventLedger.status)
+ .all();
const stats = {
- total: entries.length,
- byStatus: entries.reduce(
- (acc, e) => {
- acc[e.status] = (acc[e.status] || 0) + 1;
- return acc;
- },
- {} as Record,
- ),
- byType: entries
- .filter(e => e.type)
- .reduce(
- (acc, e) => {
- if (e.type) acc[e.type] = (acc[e.type] || 0) + 1;
- return acc;
- },
- {} as Record,
- ),
+ total: statusCounts.reduce((sum, row) => sum + row.count, 0),
+ byStatus: Object.fromEntries(statusCounts.map(row => [row.status, row.count])),
};
return {
diff --git a/packages/web/src/server/functions/admin-projects.server.ts b/packages/web/src/server/functions/admin-projects.server.ts
index c67cd6b9c..6825c55a7 100644
--- a/packages/web/src/server/functions/admin-projects.server.ts
+++ b/packages/web/src/server/functions/admin-projects.server.ts
@@ -220,66 +220,6 @@ export async function getAdminProjectDetails(session: Session, db: Database, pro
};
}
-export interface ProjectMember {
- id: string;
- userId: string;
- role: string;
- userAvatar?: string;
- userDisplayName?: string;
- userName?: string;
- userEmail?: string;
- joinedAt?: string | number | Date;
-}
-
-export interface ProjectFile {
- id: string;
- originalName?: string;
- filename?: string;
- fileType?: string;
- fileSize?: number;
- uploadedBy?: string;
- uploaderDisplayName?: string;
- uploaderName?: string;
- createdAt?: string | number | Date;
-}
-
-export interface ProjectInvitation {
- id: string;
- email: string;
- role: string;
- grantOrgMembership?: boolean;
- acceptedAt?: string | number | Date | null;
- expiresAt?: number;
- invitedBy: string;
- inviterDisplayName?: string;
- inviterName?: string;
- createdAt?: string | number | Date;
-}
-
-export interface ProjectData {
- project: {
- id: string;
- name: string;
- orgId: string;
- orgName: string;
- orgSlug: string;
- createdBy: string;
- creatorDisplayName?: string;
- creatorName?: string;
- creatorEmail?: string;
- createdAt?: string | number | Date;
- updatedAt?: string | number | Date;
- };
- stats: {
- memberCount: number;
- fileCount: number;
- totalStorageBytes: number;
- };
- members?: ProjectMember[];
- files?: ProjectFile[];
- invitations?: ProjectInvitation[];
-}
-
/** The sync-engine workspace's admin stats (`workspaceAdmin(...).stats()`). */
export interface WorkspaceStats {
workspaceId: string;
diff --git a/packages/web/src/server/functions/admin-stats.server.ts b/packages/web/src/server/functions/admin-stats.server.ts
index 1338f17c5..48b1d33e5 100644
--- a/packages/web/src/server/functions/admin-stats.server.ts
+++ b/packages/web/src/server/functions/admin-stats.server.ts
@@ -7,7 +7,7 @@ import {
organization,
stripeEventLedger,
} from '@corates/db/schema';
-import { count, gte, sql } from 'drizzle-orm';
+import { count, gt, gte, sql } from 'drizzle-orm';
import { throwDomainError, AUTH_ERRORS } from '@corates/shared';
import { isAdminUser } from '@corates/workers/auth-admin';
import { TIME_DURATIONS } from '@corates/workers/constants';
@@ -27,7 +27,7 @@ export async function getAdminStats(session: Session, db: Database) {
const [userCount, projectCount, sessionCount] = await Promise.all([
db.select({ count: count() }).from(user),
db.select({ count: count() }).from(projects),
- db.select({ count: count() }).from(sessionTable),
+ db.select({ count: count() }).from(sessionTable).where(gt(sessionTable.expiresAt, new Date())),
]);
const sevenDaysAgo = Math.floor(Date.now() / 1000) - TIME_DURATIONS.STATS_RECENT_DAYS_SEC;
@@ -195,15 +195,18 @@ export async function getAdminWebhookStats(
};
}
+const SUBSCRIPTION_STATUS_SCAN_LIMIT = 100;
+
export async function getAdminSubscriptionStats(session: Session) {
assertAdmin(session);
const stripe = createStripeClient(env.STRIPE_SECRET_KEY);
+ const limit = SUBSCRIPTION_STATUS_SCAN_LIMIT;
const statusCounts = await Promise.all([
- stripe.subscriptions.search({ query: 'status:"active"', limit: 100 }),
- stripe.subscriptions.search({ query: 'status:"trialing"', limit: 100 }),
- stripe.subscriptions.search({ query: 'status:"past_due"', limit: 100 }),
- stripe.subscriptions.search({ query: 'status:"canceled"', limit: 100 }),
+ stripe.subscriptions.search({ query: 'status:"active"', limit }),
+ stripe.subscriptions.search({ query: 'status:"trialing"', limit }),
+ stripe.subscriptions.search({ query: 'status:"past_due"', limit }),
+ stripe.subscriptions.search({ query: 'status:"canceled"', limit }),
]);
return {
@@ -211,7 +214,10 @@ export async function getAdminSubscriptionStats(session: Session) {
trialing: statusCounts[1].data.length,
pastDue: statusCounts[2].data.length,
canceled: statusCounts[3].data.length,
- hasMore: statusCounts.some(r => r.has_more),
+ // Stripe has no count API, so each status is a capped scan; once one fills
+ // its page every count here is a floor rather than a total.
+ truncated: statusCounts.some(r => r.has_more),
+ statusScanLimit: SUBSCRIPTION_STATUS_SCAN_LIMIT,
};
}
diff --git a/packages/web/src/server/functions/admin-stripe.server.ts b/packages/web/src/server/functions/admin-stripe.server.ts
index 75a600759..45ebfbed5 100644
--- a/packages/web/src/server/functions/admin-stripe.server.ts
+++ b/packages/web/src/server/functions/admin-stripe.server.ts
@@ -291,3 +291,15 @@ export async function getAdminStripeCustomerSubscriptions(
hasMore: subscriptions.has_more,
};
}
+
+export type AdminStripeCustomerLookup = Awaited>;
+export type AdminStripeCustomerFound = Extract;
+export type AdminStripeInvoice = Awaited<
+ ReturnType
+>['invoices'][number];
+export type AdminStripePaymentMethod = Awaited<
+ ReturnType
+>['paymentMethods'][number];
+export type AdminStripeSubscription = Awaited<
+ ReturnType
+>['subscriptions'][number];