From d09a1b486505c96fce1d5f5d25901dd8e2cefdee Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 12 Sep 2026 15:31:08 -0500 Subject: [PATCH 1/3] Delete the admin project types nothing imports These four interfaces moved into admin-projects.server.ts when the component-side types.ts went away, then lost their last callers when the pages switched to inferred types. They declared fields the server never selects (userDisplayName, creatorDisplayName), so leaving them around invites the next caller to trust a shape that does not exist. Claude-Session: https://claude.ai/code/session_01LqxkXwhjRDsJ1N9cBYpU1n --- .../server/functions/admin-projects.server.ts | 60 ------------------- 1 file changed, 60 deletions(-) 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; From beab91178ef87ac00e426711493a1dfb2a3f8368 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 12 Sep 2026 15:31:11 -0500 Subject: [PATCH 2/3] Let Stripe Tools read the shapes the server actually returns The page declared its own StripeCustomer, StripeSubscription, StripeInvoice and StripePaymentMethod and cast every response into them. Inferring from the server functions instead turned up two things the casts were hiding: - The subscription period line read sub.currentPeriodStart and currentPeriodEnd, which Stripe moved onto each item. Every row rendered an empty date range. - invoice.status and customer.currency are nullable in Stripe's own types; the hand-written ones said otherwise. The lookup's found/not-found union now narrows instead of being flattened into one optional-everything shape, so the not-found branch no longer pretends to carry a customer. Loading invoices or subscriptions keeps the hasMore flag the server already returned, so a capped list reads "Subscriptions (20+)" rather than claiming the customer has exactly twenty. Claude-Session: https://claude.ai/code/session_01LqxkXwhjRDsJ1N9cBYpU1n --- .../_protected/admin/billing.stripe-tools.tsx | 116 ++++++------------ .../server/functions/admin-stripe.server.ts | 12 ++ 2 files changed, 52 insertions(+), 76 deletions(-) diff --git a/packages/web/src/routes/_app/_protected/admin/billing.stripe-tools.tsx b/packages/web/src/routes/_app/_protected/admin/billing.stripe-tools.tsx index 801256c0f..5b0b35db1 100644 --- a/packages/web/src/routes/_app/_protected/admin/billing.stripe-tools.tsx +++ b/packages/web/src/routes/_app/_protected/admin/billing.stripe-tools.tsx @@ -46,65 +46,18 @@ import { SelectValue, } from '@/components/ui/select'; import { formatDateTime } from '@/lib/formatDate'; - -interface StripeCustomer { - id: string; - email?: string; - name?: string; - created?: number; - balance?: number; - currency?: string; - delinquent?: boolean; - livemode?: boolean; -} - -interface CustomerData { - found: boolean; - message?: string; - customer: StripeCustomer; - stripeDashboardUrl?: string; - linkedUser?: { id: string; name?: string; email?: string }; - linkedOrg?: { id: string; name?: string }; -} - -interface StripeSubscription { - id: string; - status: string; - currentPeriodStart?: number; - currentPeriodEnd?: number; - cancelAtPeriodEnd?: boolean; - trialEnd?: number; - currency?: string; - items?: Array<{ unitAmount?: number; interval?: string }>; -} - -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/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]; From 0116fa32a6dee853b0802d01a5cfc36cec05e95f Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 12 Sep 2026 15:31:14 -0500 Subject: [PATCH 3/3] 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 --- .../src/components/admin/AnalyticsSection.tsx | 5 ++ .../_app/_protected/admin/billing.ledger.tsx | 7 +++ ...admin-billing-observability.server.test.ts | 42 +++++++++++++++ .../__tests__/admin-stats.server.test.ts | 2 +- .../__tests__/admin-users.server.test.ts | 17 +++++- .../server/functions/admin-billing.server.ts | 53 +++++++------------ .../server/functions/admin-stats.server.ts | 20 ++++--- 7 files changed, 103 insertions(+), 43 deletions(-) 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={ <> { 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-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, }; }