diff --git a/src/api/billing.ts b/src/api/billing.ts index a5be30fc23..c18e5533cf 100644 --- a/src/api/billing.ts +++ b/src/api/billing.ts @@ -2,9 +2,7 @@ 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', @@ -90,41 +88,6 @@ export interface Invoice { }; } -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(); -}; - export interface MultiplePaymentMethods { responses: any[]; errors: any[]; diff --git a/src/api/gql/billing.ts b/src/api/gql/billing.ts new file mode 100644 index 0000000000..0e98929413 --- /dev/null +++ b/src/api/gql/billing.ts @@ -0,0 +1,29 @@ +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 + } + } + } + } + } +`); diff --git a/src/components/admin/Billing/LoadError.tsx b/src/components/admin/Billing/LoadError.tsx index 630fb98d4c..34a430006d 100644 --- a/src/components/admin/Billing/LoadError.tsx +++ b/src/components/admin/Billing/LoadError.tsx @@ -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; } diff --git a/src/components/admin/Billing/PricingTierDetails.tsx b/src/components/admin/Billing/PricingTierDetails.tsx index 20db091910..8102906886 100644 --- a/src/components/admin/Billing/PricingTierDetails.tsx +++ b/src/components/admin/Billing/PricingTierDetails.tsx @@ -13,7 +13,6 @@ function PricingTierDetails() { const [externalPaymentMethod, marketPlaceProvider] = useTenantUsesExternalPayment(selectedTenant); - const billingStoreHydrated = useBillingStore((state) => state.hydrated); const paymentMethodExists = useBillingStore( (state) => state.paymentMethodExists ); @@ -38,7 +37,7 @@ function PricingTierDetails() { return 'admin.billing.message.freeTier'; }, [externalPaymentMethod, marketPlaceProvider, paymentMethodExists]); - if (!billingStoreHydrated || typeof paymentMethodExists !== 'boolean') { + if (typeof paymentMethodExists !== 'boolean') { return ( diff --git a/src/components/admin/Billing/TenantOptions.tsx b/src/components/admin/Billing/TenantOptions.tsx index 17466aaa7a..54d6599057 100644 --- a/src/components/admin/Billing/TenantOptions.tsx +++ b/src/components/admin/Billing/TenantOptions.tsx @@ -1,16 +1,7 @@ -import { useCallback } from 'react'; - import TenantSelector from 'src/components/shared/TenantSelector'; -import { useBillingStore } from 'src/stores/Billing'; function TenantOptions() { - const resetBillingState = useBillingStore((state) => state.resetState); - - const updateStoreState = useCallback(() => { - resetBillingState(); - }, [resetBillingState]); - - return ; + return ; } export default TenantOptions; diff --git a/src/components/admin/Billing/index.tsx b/src/components/admin/Billing/index.tsx index 4e2892fc40..3c0b8e8cb9 100644 --- a/src/components/admin/Billing/index.tsx +++ b/src/components/admin/Billing/index.tsx @@ -1,18 +1,10 @@ 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'; @@ -28,14 +20,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; @@ -52,79 +40,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 ( <> @@ -178,7 +94,7 @@ function AdminBilling({ showAddPayment }: AdminBillingProps) { - {!active && hydrated ? ( + {!isLoading ? ( state.hydrated); - const invoices = useBillingStore((state) => state.invoices); + const { invoices, isLoading } = useBillingInvoices(); const resizeListener = useRef(null); const [myChart, setMyChart] = useState(null); @@ -85,7 +84,7 @@ function TaskHoursByMonthGraph() { }, [invoices, intl, today]); useEffect(() => { - if (billingStoreHydrated && invoices.length > 0) { + if (!isLoading && invoices.length > 0) { if (!myChart) { echarts.use([ GridComponent, @@ -190,7 +189,7 @@ function TaskHoursByMonthGraph() { } }, [ invoices, - billingStoreHydrated, + isLoading, intl, months, myChart, diff --git a/src/components/graphs/UsageByMonthGraph.tsx b/src/components/graphs/UsageByMonthGraph.tsx index 0de4754ab3..20d6624c4a 100644 --- a/src/components/graphs/UsageByMonthGraph.tsx +++ b/src/components/graphs/UsageByMonthGraph.tsx @@ -27,7 +27,7 @@ import { useIntl } from 'react-intl'; import useLegendConfig from 'src/components/graphs/useLegendConfig'; import useTooltipConfig from 'src/components/graphs/useTooltipConfig'; import { eChartsColors } from 'src/context/Theme'; -import { useBillingStore } from 'src/stores/Billing'; +import { useBillingInvoices } from 'src/hooks/billing/useBillingInvoices'; import { CARD_AREA_HEIGHT, stripTimeFromDate } from 'src/utils/billing-utils'; const chartContainerId = 'data-by-month'; @@ -40,8 +40,7 @@ function UsageByMonthGraph() { const tooltipConfig = useTooltipConfig(); const legendConfig = useLegendConfig([{ name: 'Data' }, { name: 'Hours' }]); - const billingStoreHydrated = useBillingStore((state) => state.hydrated); - const invoices = useBillingStore((state) => state.invoices); + const { invoices, isLoading } = useBillingInvoices(); const [myChart, setMyChart] = useState(null); @@ -96,7 +95,7 @@ function UsageByMonthGraph() { }, [invoices, intl, today]); useEffect(() => { - if (billingStoreHydrated && invoices.length > 0) { + if (!isLoading && invoices.length > 0) { if (!myChart) { echarts.use([ GridComponent, @@ -126,7 +125,7 @@ function UsageByMonthGraph() { return undefined; }, [ invoices, - billingStoreHydrated, + isLoading, intl, legendConfig, months, diff --git a/src/components/graphs/states/Wrapper.tsx b/src/components/graphs/states/Wrapper.tsx index 00693b82a2..f81e410f4e 100644 --- a/src/components/graphs/states/Wrapper.tsx +++ b/src/components/graphs/states/Wrapper.tsx @@ -7,14 +7,15 @@ import { FormattedMessage } from 'react-intl'; import EmptyGraphState from 'src/components/graphs/states/Empty'; import GraphLoadingState from 'src/components/graphs/states/Loading'; import { eChartsTooltipSX } from 'src/components/graphs/tooltips'; -import { useBillingStore } from 'src/stores/Billing'; +import { useBillingInvoices } from 'src/hooks/billing/useBillingInvoices'; import { hasLength } from 'src/utils/misc-utils'; function GraphStateWrapper({ children }: BaseComponentProps) { - const billingStoreActive = useBillingStore((state) => state.active); - const billingStoreHydrated = useBillingStore((state) => state.hydrated); - const networkFailed = useBillingStore((state) => state.networkFailed); - const billingHistory = useBillingStore((state) => state.invoices); + const { + invoices: billingHistory, + isLoading, + networkFailed, + } = useBillingInvoices(); if (networkFailed) { return ( @@ -29,7 +30,7 @@ function GraphStateWrapper({ children }: BaseComponentProps) { ); } - if (!billingStoreActive && billingStoreHydrated) { + if (!isLoading) { return hasLength(billingHistory) ? ( {children} ) : ( diff --git a/src/components/tables/BillLineItems/index.tsx b/src/components/tables/BillLineItems/index.tsx index 5c1dcbce40..69ed0244ac 100644 --- a/src/components/tables/BillLineItems/index.tsx +++ b/src/components/tables/BillLineItems/index.tsx @@ -22,10 +22,7 @@ import TotalLines from 'src/components/tables/BillLineItems/TotalLines'; import EntityTableBody from 'src/components/tables/EntityTable/TableBody'; import EntityTableHeader from 'src/components/tables/EntityTable/TableHeader'; import { getTableHeaderWithoutHeaderColor } from 'src/context/Theme'; -import { - useBilling_selectedInvoice, - useBillingStore, -} from 'src/stores/Billing'; +import { useBillingInvoices } from 'src/hooks/billing/useBillingInvoices'; import { useTenantStore } from 'src/stores/Tenant'; import { TableStatuses } from 'src/types'; @@ -57,10 +54,7 @@ function BillingLineItemsTable() { const selectedTenant = useTenantStore((state) => state.selectedTenant); - const selectedInvoice = useBilling_selectedInvoice(); - - const hydrated = useBillingStore((state) => state.hydrated); - const invoices = useBillingStore((state) => state.invoices); + const { invoices, selectedInvoice, isLoading } = useBillingInvoices(); const dataRows = useMemo( () => , @@ -120,7 +114,7 @@ function BillingLineItemsTable() { ? { status: TableStatuses.DATA_FETCHED } : { status: TableStatuses.NO_EXISTING_DATA } } - loading={!hydrated} + loading={isLoading} rows={dataRows} /> @@ -135,7 +129,7 @@ function BillingLineItemsTable() { }} > {selectedInvoice?.invoice_type !== 'preview' ? ( - hydrated ? ( + !isLoading ? (