diff --git a/packages/web/src/components/admin/AnalyticsSection.tsx b/packages/web/src/components/admin/AnalyticsSection.tsx index 016e2fd75..0a02067d8 100644 --- a/packages/web/src/components/admin/AnalyticsSection.tsx +++ b/packages/web/src/components/admin/AnalyticsSection.tsx @@ -299,6 +299,11 @@ export function AnalyticsSection() {
+ Showing the {entries.length} most recent of {stats.total}. + + : undefined + } action={ <> ; -} - -interface StripeInvoice { - id: string; - number?: string; - status: string; - total?: number; - currency?: string; - created?: number; - hostedInvoiceUrl?: string; - invoicePdf?: string; -} - -interface StripePaymentMethod { - id: string; - card?: { - brand?: string; - last4?: string; - expMonth?: number; - expYear?: number; - funding?: string; - }; -} - -const formatCurrency = (amount: number | null | undefined, currency = 'usd'): string => { +import type { + AdminStripeCustomerFound, + AdminStripeInvoice, + AdminStripePaymentMethod, + AdminStripeSubscription, +} from '@/server/functions/admin-stripe.server'; + +const formatCurrency = (amount: number | null | undefined, currency?: string | null): string => { if (amount === null || amount === undefined) return '-'; return new Intl.NumberFormat('en-US', { style: 'currency', - currency: currency.toUpperCase(), + currency: (currency || 'usd').toUpperCase(), }).format(amount / 100); }; @@ -137,15 +90,21 @@ function StripeToolsPage() { const [searchType, setSearchType] = useState<'email' | 'customerId'>('email'); const [searchInput, setSearchInput] = useState(''); const [searching, setSearching] = useState(false); - const [customerData, setCustomerData] = useState(null); + const [customerData, setCustomerData] = useState(null); const [searchError, setSearchError] = useState(null); const [loadingInvoices, setLoadingInvoices] = useState(false); const [loadingPaymentMethods, setLoadingPaymentMethods] = useState(false); const [loadingSubscriptions, setLoadingSubscriptions] = useState(false); - const [invoices, setInvoices] = useState(null); - const [paymentMethods, setPaymentMethods] = useState(null); - const [subscriptions, setSubscriptions] = useState(null); + const [invoices, setInvoices] = useState<{ + rows: AdminStripeInvoice[]; + hasMore: boolean; + } | null>(null); + const [paymentMethods, setPaymentMethods] = useState(null); + const [subscriptions, setSubscriptions] = useState<{ + rows: AdminStripeSubscription[]; + hasMore: boolean; + } | null>(null); const [generatingPortal, setGeneratingPortal] = useState(false); const [portalUrl, setPortalUrl] = useState(null); @@ -166,10 +125,11 @@ function StripeToolsPage() { const query = searchType === 'email' ? { email: searchInput.trim() } : { customerId: searchInput.trim() }; - const data = (await lookupAdminStripeCustomerAction({ data: query })) as CustomerData; - setCustomerData(data); + const data = await lookupAdminStripeCustomerAction({ data: query }); - if (!data.found) { + if (data.found) { + setCustomerData(data); + } else { setSearchError(data.message || 'Customer not found'); } } catch (error) { @@ -189,7 +149,7 @@ function StripeToolsPage() { const data = await getAdminStripeCustomerInvoicesAction({ data: { customerId: customerData.customer.id }, }); - setInvoices(data.invoices as StripeInvoice[]); + setInvoices({ rows: data.invoices, hasMore: data.hasMore }); } catch (error) { showToast.error('Failed to load invoices', (error as Error).message); } finally { @@ -205,7 +165,7 @@ function StripeToolsPage() { const data = await getAdminStripeCustomerPaymentMethodsAction({ data: { customerId: customerData.customer.id }, }); - setPaymentMethods(data.paymentMethods as StripePaymentMethod[]); + setPaymentMethods(data.paymentMethods); } catch (error) { showToast.error('Failed to load payment methods', (error as Error).message); } finally { @@ -221,7 +181,7 @@ function StripeToolsPage() { const data = await getAdminStripeCustomerSubscriptionsAction({ data: { customerId: customerData.customer.id }, }); - setSubscriptions(data.subscriptions as StripeSubscription[]); + setSubscriptions({ rows: data.subscriptions, hasMore: data.hasMore }); } catch (error) { showToast.error('Failed to load subscriptions', (error as Error).message); } finally { @@ -427,12 +387,12 @@ function StripeToolsPage() { {subscriptions && ( - {subscriptions.length === 0 ? + {subscriptions.rows.length === 0 ? - : subscriptions.map(sub => ( + : subscriptions.rows.map(sub => (
@@ -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];