Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/web/src/components/admin/AnalyticsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,11 @@ export function AnalyticsSection() {
<div className='grid grid-cols-1 gap-6 lg:grid-cols-3'>
<AdminPanel
title='Subscriptions'
description={
subscriptionData?.truncated ?
`At least ${subscriptionData.statusScanLimit} in one status - counts are floors`
: undefined
}
padded
action={
<RefreshButton
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,13 @@ function AdminBillingLedgerPage() {

<AdminPanel
title='Events'
footer={
stats && entries.length < stats.total ?
<span className='text-muted-foreground text-[13px]'>
Showing the {entries.length} most recent of {stats.total}.
</span>
: undefined
}
action={
<>
<Input
Expand Down
116 changes: 40 additions & 76 deletions packages/web/src/routes/_app/_protected/admin/billing.stripe-tools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};

Expand Down Expand Up @@ -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<CustomerData | null>(null);
const [customerData, setCustomerData] = useState<AdminStripeCustomerFound | null>(null);
const [searchError, setSearchError] = useState<string | null>(null);

const [loadingInvoices, setLoadingInvoices] = useState(false);
const [loadingPaymentMethods, setLoadingPaymentMethods] = useState(false);
const [loadingSubscriptions, setLoadingSubscriptions] = useState(false);
const [invoices, setInvoices] = useState<StripeInvoice[] | null>(null);
const [paymentMethods, setPaymentMethods] = useState<StripePaymentMethod[] | null>(null);
const [subscriptions, setSubscriptions] = useState<StripeSubscription[] | null>(null);
const [invoices, setInvoices] = useState<{
rows: AdminStripeInvoice[];
hasMore: boolean;
} | null>(null);
const [paymentMethods, setPaymentMethods] = useState<AdminStripePaymentMethod[] | null>(null);
const [subscriptions, setSubscriptions] = useState<{
rows: AdminStripeSubscription[];
hasMore: boolean;
} | null>(null);

const [generatingPortal, setGeneratingPortal] = useState(false);
const [portalUrl, setPortalUrl] = useState<string | null>(null);
Expand All @@ -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) {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -427,12 +387,12 @@ function StripeToolsPage() {

{subscriptions && (
<AdminPanel
title={`Subscriptions (${subscriptions.length})`}
title={`Subscriptions (${subscriptions.rows.length}${subscriptions.hasMore ? '+' : ''})`}
bodyClassName='divide-border divide-y'
>
{subscriptions.length === 0 ?
{subscriptions.rows.length === 0 ?
<AdminEmpty title='No subscriptions' />
: subscriptions.map(sub => (
: subscriptions.rows.map(sub => (
<div key={sub.id} className='flex items-start justify-between gap-4 px-4 py-3'>
<div className='min-w-0'>
<div className='flex flex-wrap items-center gap-2'>
Expand All @@ -443,13 +403,13 @@ function StripeToolsPage() {
)}
</div>
<p className='text-muted-foreground mt-1 text-xs'>
{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)}`}
</p>
{sub.items && sub.items.length > 0 && (
{sub.items.length > 0 && (
<p className='text-muted-foreground mt-1 text-xs'>
{sub.items
.map(
Expand All @@ -476,8 +436,10 @@ function StripeToolsPage() {
)}

{invoices && (
<AdminPanel title={`Recent Invoices (${invoices.length})`}>
{invoices.length === 0 ?
<AdminPanel
title={`Recent Invoices (${invoices.rows.length}${invoices.hasMore ? '+' : ''})`}
>
{invoices.rows.length === 0 ?
<AdminEmpty title='No invoices' />
: <Table>
<TableHeader className='bg-muted/40'>
Expand All @@ -490,13 +452,15 @@ function StripeToolsPage() {
</TableRow>
</TableHeader>
<TableBody>
{invoices.map(invoice => (
{invoices.rows.map(invoice => (
<TableRow key={invoice.id} className='border-border'>
<TableCell className={`${ADMIN_TD} font-mono`}>
{invoice.number || invoice.id}
</TableCell>
<TableCell className={ADMIN_TD}>
<Badge variant={getStatusVariant(invoice.status)}>{invoice.status}</Badge>
<Badge variant={getStatusVariant(invoice.status ?? 'unknown')}>
{invoice.status ?? 'unknown'}
</Badge>
</TableCell>
<TableCell className={`${ADMIN_TD} text-right tabular-nums`}>
{formatCurrency(invoice.total, invoice.currency)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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', () => {
Expand Down
53 changes: 20 additions & 33 deletions packages/web/src/server/functions/admin-billing.server.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<string, number>,
),
byType: entries
.filter(e => e.type)
.reduce(
(acc, e) => {
if (e.type) acc[e.type] = (acc[e.type] || 0) + 1;
return acc;
},
{} as Record<string, number>,
),
total: statusCounts.reduce((sum, row) => sum + row.count, 0),
byStatus: Object.fromEntries(statusCounts.map(row => [row.status, row.count])),
};

return {
Expand Down
Loading
Loading