Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7faacc2
move help and user menus, create org menu, remove prefix selector fro…
GregorShear Jun 6, 2026
a712916
OrgMenu: default to the first available tenant on render
GregorShear Jun 15, 2026
62d6a9d
Fix Prettier formatting in Billing index
GregorShear Jun 15, 2026
faaf8be
OrgMenu: honor ?prefix= deep-link and use one org list for all users
GregorShear Jun 15, 2026
6cc847b
OrgMenu: keep the support autocomplete dialog, validate per-user list
GregorShear Jun 15, 2026
d0675b2
OrgMenu: support dialog lists the same tenants as the old selector
GregorShear Jun 15, 2026
341f36a
Add a logout escape hatch to the legal-check error screen
GregorShear Jun 15, 2026
a179417
Always offer logout on the full-page error, not per call site
GregorShear Jun 15, 2026
496597c
Add a Reload button beside Logout on the full-page error
GregorShear Jun 15, 2026
ad7450a
Revert PrefixedName changes; leave component untouched in this PR
GregorShear Jun 15, 2026
2fc90c7
clean up comments
GregorShear Jun 15, 2026
8e4c2b9
Move tenant-selection bootstrap from OrgMenu into TenantGuard
GregorShear Jun 15, 2026
b3e4b00
Document why userDetails.emailVerified is effectively always true
GregorShear Jun 15, 2026
f71f0e6
tweak exports
GregorShear Jun 15, 2026
1f93b3c
Consolidate side-nav rows into ListItemLink and simplify the menus
GregorShear Jun 16, 2026
233eee3
update comments
GregorShear Jun 16, 2026
24d8bea
Key billing data on the selected tenant via SWR
GregorShear Jun 16, 2026
7c1ac80
Fetch billing invoices via GraphQL instead of PostgREST
GregorShear Jun 16, 2026
45430fd
Register billing GQL types as keyless in the urql cache
GregorShear Jun 16, 2026
dd2cf5e
Surface invoice PDF/receipt links from the GQL invoice node
GregorShear Jun 16, 2026
4c790d7
Show 'No invoice available' and open invoice links in a new tab
GregorShear Jun 16, 2026
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
72 changes: 7 additions & 65 deletions src/api/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,42 +2,13 @@ import type { TenantPaymentDetails } from 'src/types';

import pLimit from 'p-limit';

import { supabaseClient } from 'src/context/GlobalProviders';
import { FUNCTIONS, invokeSupabase, TABLES } from 'src/services/supabase';
import { formatDateForApi } from 'src/utils/billing-utils';
import { FUNCTIONS, invokeSupabase } from 'src/services/supabase';

const OPERATIONS = {
SETUP_INTENT: 'setup-intent',
GET_TENANT_PAYMENT_METHODS: 'get-tenant-payment-methods',
DELETE_TENANT_PAYMENT_METHODS: 'delete-tenant-payment-method',
SET_PRIMARY: 'set-tenant-primary-payment-method',
GET_TENANT_INVOICE: 'get-tenant-invoice',
};

export interface StripeInvoice {
id: string;
amount_due: number;
invoice_pdf: string;
hosted_invoice_url: string;
status: 'open' | 'paid' | 'void' | 'uncollectable';
}

export const getTenantInvoice = (
tenant: string,
date_start: string,
date_end: string,
type: 'manual' | 'final'
) => {
return invokeSupabase<{ invoice?: StripeInvoice | null }>(
FUNCTIONS.BILLING,
{
operation: OPERATIONS.GET_TENANT_INVOICE,
tenant,
date_start,
date_end,
type,
}
);
};

export const getSetupIntentSecret = (tenant: string) => {
Expand Down Expand Up @@ -88,43 +59,14 @@ export interface Invoice {
processed_data_gb: number;
task_usage_hours: number;
};
// Stripe-sourced fields carried on the GQL invoice node. Absent on previews
// and on invoices that haven't been issued in Stripe yet.
status?: string | null;
invoice_pdf?: string | null;
hosted_invoice_url?: string | null;
receipt_url?: string | null;
}

const invoicesQuery = [
'billed_prefix',
'date_start',
'date_end',
'line_items',
'subtotal',
'invoice_type',
'extra',
].join(', ');

export const getInvoicesBetween = (
billed_prefix: string,
date_start: Date,
date_end: Date
) => {
const formattedStart = formatDateForApi(date_start);
const formattedEnd = formatDateForApi(date_end);

return supabaseClient
.from(TABLES.INVOICES_EXT)
.select(invoicesQuery)
.filter('billed_prefix', 'eq', billed_prefix)
.or(
`invoice_type.eq.manual,and(${[
`date_start.gte.${formattedStart}`,
`date_start.lte.${formattedEnd}`,
`date_end.gte.${formattedStart}`,
`date_end.lte.${formattedEnd}`,
].join(',')})`
)
.order('date_start', { ascending: false })
.throwOnError()
.returns<Invoice[]>();
};

export interface MultiplePaymentMethods {
responses: any[];
errors: any[];
Expand Down
36 changes: 36 additions & 0 deletions src/api/gql/billing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { graphql } from 'src/gql-types';

// Upper bound on invoices fetched per tenant. A tenant accrues ~12 invoices a
// year, so this comfortably covers the rolling six-month window the UI shows
// plus any older manual invoices, which the hook filters down client-side.
export const BILLING_INVOICE_FETCH_LIMIT = 100;

// `lineItems` and `extra` are opaque JSON scalars in the schema, so codegen
// types them as `unknown`; the hook casts them to the InvoiceLineItem[] / extra
// shapes the rest of the billing UI already expects. `billed_prefix` is not on
// the node because the tenant is implied by the `tenant(name:)` parent.
export const TENANT_BILLING_INVOICES_QUERY = graphql(`
query TenantBillingInvoices($tenant: String!, $first: Int) {
tenant(name: $tenant) {
billing {
invoices(first: $first) {
nodes {
dateStart
dateEnd
invoiceType
subtotal
lineItems
extra
status
invoicePdf
hostedInvoiceUrl
paymentDetails {
status
receiptUrl
}
}
}
}
}
}
`);
3 changes: 3 additions & 0 deletions src/app/guards/TenantGuard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useUserInfoSummaryStore } from 'src/context/UserInfoSummary/useUserInfo
import useGlobalSearchParams, {
GlobalSearchParams,
} from 'src/hooks/searchParams/useGlobalSearchParams';
import { useInitializeSelectedTenant } from 'src/hooks/useInitializeSelectedTenant';

// This is a way to very simply "hide" the flow where anyone
// can create a tenant but allow us to test it out in prod.
Expand All @@ -27,6 +28,8 @@ function TenantGuard({ children }: BaseComponentProps) {
const mutate = useUserInfoSummaryStore((state) => state.mutate);
const usedSSO = useUserStore((state) => state.userDetails?.usedSSO);

useInitializeSelectedTenant();

const showOnboarding = !hasAnyAccess || showBeta;
if (showOnboarding) {
if (usedSSO && !showBeta) {
Expand Down
8 changes: 3 additions & 5 deletions src/components/admin/Billing/LoadError.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@ import { Grid } from '@mui/material';
import { FormattedMessage } from 'react-intl';

import AlertBox from 'src/components/shared/AlertBox';
import { useBillingStore } from 'src/stores/Billing';
import { useBillingInvoices } from 'src/hooks/billing/useBillingInvoices';

function BillingLoadError() {
const hydrationErrorsExist = useBillingStore(
(state) => state.hydrationErrorsExist
);
const { errorExists } = useBillingInvoices();

if (!hydrationErrorsExist) {
if (!errorExists) {
return null;
}

Expand Down
3 changes: 1 addition & 2 deletions src/components/admin/Billing/PricingTierDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ function PricingTierDetails() {
const [externalPaymentMethod, marketPlaceProvider] =
useTenantUsesExternalPayment(selectedTenant);

const billingStoreHydrated = useBillingStore((state) => state.hydrated);
const paymentMethodExists = useBillingStore(
(state) => state.paymentMethodExists
);
Expand All @@ -38,7 +37,7 @@ function PricingTierDetails() {
return 'admin.billing.message.freeTier';
}, [externalPaymentMethod, marketPlaceProvider, paymentMethodExists]);

if (!billingStoreHydrated || typeof paymentMethodExists !== 'boolean') {
if (typeof paymentMethodExists !== 'boolean') {
return (
<Skeleton>
<Typography>
Expand Down
16 changes: 0 additions & 16 deletions src/components/admin/Billing/TenantOptions.tsx

This file was deleted.

100 changes: 4 additions & 96 deletions src/components/admin/Billing/index.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,16 @@
import type { AdminBillingProps } from 'src/components/admin/Billing/types';

import { useEffect, useMemo } from 'react';
import useConstant from 'use-constant';

import { Divider, Grid, Typography } from '@mui/material';

import { useShallow } from 'zustand/react/shallow';

import { endOfMonth, startOfMonth, subMonths } from 'date-fns';
import { ErrorBoundary } from 'react-error-boundary';
import { useIntl } from 'react-intl';
import { useUnmount } from 'react-use';

import { getInvoicesBetween } from 'src/api/billing';
import { authenticatedRoutes } from 'src/app/routes';
import DateRange from 'src/components/admin/Billing/DateRange';
import BillingLoadError from 'src/components/admin/Billing/LoadError';
import PaymentMethods from 'src/components/admin/Billing/PaymentMethods';
import PricingTierDetails from 'src/components/admin/Billing/PricingTierDetails';
import { INVOICE_ROW_HEIGHT } from 'src/components/admin/Billing/shared';
import TenantOptions from 'src/components/admin/Billing/TenantOptions';
import AdminTabs from 'src/components/admin/Tabs';
import GraphLoadingState from 'src/components/graphs/states/Loading';
import GraphStateWrapper from 'src/components/graphs/states/Wrapper';
Expand All @@ -28,14 +19,10 @@ import AlertBox from 'src/components/shared/AlertBox';
import CardWrapper from 'src/components/shared/CardWrapper';
import BillingHistoryTable from 'src/components/tables/Billing';
import BillingLineItemsTable from 'src/components/tables/BillLineItems';
import { useBillingInvoices } from 'src/hooks/billing/useBillingInvoices';
import usePageTitle from 'src/hooks/usePageTitle';
import { logRocketEvent } from 'src/services/shared';
import { CustomEvents } from 'src/services/types';
import {
useBilling_selectedInvoice,
useBillingStore,
} from 'src/stores/Billing';
import { useTenantStore } from 'src/stores/Tenant';
import { invoiceId, TOTAL_CARD_HEIGHT } from 'src/utils/billing-utils';

const routeTitle = authenticatedRoutes.admin.billing.title;
Expand All @@ -52,79 +39,7 @@ function AdminBilling({ showAddPayment }: AdminBillingProps) {

const intl = useIntl();

const selectedTenant = useTenantStore((state) => state.selectedTenant);

// Billing Store
// TODO (billing store)
// The `active` stuff could probably be removed now that other stuff is
// cleaned up - but leaving to make it easier
const [active, setActive] = useBillingStore(
useShallow((state) => [state.active, state.setActive])
);
const [hydrated, setHydrated] = useBillingStore(
useShallow((state) => [state.hydrated, state.setHydrated])
);
const setHydrationErrorsExist = useBillingStore(
(state) => state.setHydrationErrorsExist
);
const setInvoices = useBillingStore((state) => state.setInvoices);
const setNetworkFailed = useBillingStore((state) => state.setNetworkFailed);

const selectedInvoice = useBilling_selectedInvoice();

const resetBillingState = useBillingStore((state) => state.resetState);

const currentMonth = useConstant(() => {
const today = new Date();

return endOfMonth(today);
});

const dateRange = useMemo(() => {
const startMonth = startOfMonth(subMonths(currentMonth, 5));

return { start: startMonth, end: currentMonth };
}, [currentMonth]);

useEffect(() => {
if (selectedTenant) {
void (async () => {
setNetworkFailed(null);
setActive(true);
try {
const response = await getInvoicesBetween(
selectedTenant,
dateRange.start,
dateRange.end
);
if (response.error) {
throw new Error(response.error.message);
}
setNetworkFailed(null);
setHydrationErrorsExist(false);
setInvoices(response.data);
} catch (errorMessage: unknown) {
setNetworkFailed(`${errorMessage}`);
setHydrationErrorsExist(true);
setInvoices([]);
} finally {
setHydrated(true);
setActive(false);
}
})();
}
}, [
dateRange.end,
dateRange.start,
selectedTenant,
setActive,
setHydrated,
setHydrationErrorsExist,
setInvoices,
setNetworkFailed,
]);

useUnmount(() => resetBillingState());
const { isLoading, selectedInvoice } = useBillingInvoices();

return (
<>
Expand All @@ -138,13 +53,6 @@ function AdminBilling({ showAddPayment }: AdminBillingProps) {

<PricingTierDetails />
</Grid>

<Grid
size={{ xs: 12, md: 3 }}
sx={{ display: 'flex', alignItems: 'end' }}
>
<TenantOptions />
</Grid>
</Grid>

<Grid container spacing={{ xs: 3, md: 2 }} sx={{ p: 2 }}>
Expand Down Expand Up @@ -178,7 +86,7 @@ function AdminBilling({ showAddPayment }: AdminBillingProps) {
<CardWrapper
height={invoiceCardHeight}
message={
active || !hydrated ? (
isLoading ? (
intl.formatMessage({
id: 'admin.billing.label.lineItems.loading',
})
Expand All @@ -199,7 +107,7 @@ function AdminBilling({ showAddPayment }: AdminBillingProps) {
)
}
>
{!active && hydrated ? (
{!isLoading ? (
<BillingLineItemsTable
// The key here makes sure that any stateful fetching logic doesn't get confused.
key={
Expand Down
16 changes: 1 addition & 15 deletions src/components/admin/Settings/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { Divider, Grid, Stack } from '@mui/material';
import { Divider, Stack } from '@mui/material';

import { authenticatedRoutes } from 'src/app/routes';
import DataPlanes from 'src/components/admin/Settings/DataPlanes';
import PrefixAlerts from 'src/components/admin/Settings/PrefixAlerts';
import { StorageMappings } from 'src/components/admin/Settings/StorageMappings';
import AdminTabs from 'src/components/admin/Tabs';
import TenantSelector from 'src/components/shared/TenantSelector';
import usePageTitle from 'src/hooks/usePageTitle';

function Settings() {
Expand All @@ -17,19 +16,6 @@ function Settings() {
<>
<AdminTabs />

<Grid
container
spacing={{ xs: 3, md: 2 }}
sx={{ p: 2, justifyContent: 'flex-end' }}
>
<Grid
size={{ xs: 12, md: 3 }}
sx={{ mt: 2.5, display: 'flex', alignItems: 'end' }}
>
<TenantSelector />
</Grid>
</Grid>

<PrefixAlerts />

<Stack>
Expand Down
Loading