diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/billing-summary.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/billing-summary.tsx index a98c03f6b4a..1018fc01b24 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/billing-summary.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/billing-summary.tsx @@ -1,9 +1,10 @@ import { createFileRoute } from '@tanstack/react-router' +import { SettingsBillingSummary } from '@qovery/domains/organizations/feature' export const Route = createFileRoute('/_authenticated/organization/$organizationId/settings/billing-summary')({ component: RouteComponent, }) function RouteComponent() { - return
Hello "/_authenticated/organization/$organizationId/settings/billing-summary"!
+ return } diff --git a/libs/domains/organizations/feature/src/index.ts b/libs/domains/organizations/feature/src/index.ts index 8a6505183f8..bb2c5e8bad2 100644 --- a/libs/domains/organizations/feature/src/index.ts +++ b/libs/domains/organizations/feature/src/index.ts @@ -94,6 +94,7 @@ export * from './lib/settings-cloud-credentials/settings-cloud-credentials' export * from './lib/settings-git-repository-access/settings-git-repository-access' export * from './lib/settings-helm-repositories/settings-helm-repositories' export * from './lib/settings-container-registries/settings-container-registries' +export * from './lib/settings-billing-summary/settings-billing-summary' export * from './lib/settings-webhook/settings-webhook' export * from './lib/settings-api-token/settings-api-token' export * from './lib/settings-danger-zone/settings-danger-zone' diff --git a/libs/domains/organizations/feature/src/lib/hooks/use-credit-cards/use-credit-cards.ts b/libs/domains/organizations/feature/src/lib/hooks/use-credit-cards/use-credit-cards.ts index 2b173cd98a6..b1d8a4e20ce 100644 --- a/libs/domains/organizations/feature/src/lib/hooks/use-credit-cards/use-credit-cards.ts +++ b/libs/domains/organizations/feature/src/lib/hooks/use-credit-cards/use-credit-cards.ts @@ -3,12 +3,14 @@ import { queries } from '@qovery/state/util-queries' export interface UseCreditCardsProps { organizationId: string + enabled?: boolean suspense?: boolean } -export function useCreditCards({ organizationId, suspense = false }: UseCreditCardsProps) { +export function useCreditCards({ organizationId, enabled = true, suspense = false }: UseCreditCardsProps) { return useQuery({ ...queries.organizations.creditCards({ organizationId }), + enabled, suspense, }) } diff --git a/libs/domains/organizations/feature/src/lib/hooks/use-current-cost/use-current-cost.ts b/libs/domains/organizations/feature/src/lib/hooks/use-current-cost/use-current-cost.ts index 04ef7fc0062..82ba8bae67a 100644 --- a/libs/domains/organizations/feature/src/lib/hooks/use-current-cost/use-current-cost.ts +++ b/libs/domains/organizations/feature/src/lib/hooks/use-current-cost/use-current-cost.ts @@ -3,11 +3,15 @@ import { queries } from '@qovery/state/util-queries' export interface UseCurrentCostProps { organizationId: string + enabled?: boolean + suspense?: boolean } -export function useCurrentCost({ organizationId }: UseCurrentCostProps) { +export function useCurrentCost({ organizationId, enabled = true, suspense = false }: UseCurrentCostProps) { return useQuery({ ...queries.organizations.currentCost({ organizationId }), + enabled, + suspense, }) } diff --git a/libs/domains/organizations/feature/src/lib/hooks/use-invoices/use-invoices.ts b/libs/domains/organizations/feature/src/lib/hooks/use-invoices/use-invoices.ts index c08edcc3b6f..750851a597e 100644 --- a/libs/domains/organizations/feature/src/lib/hooks/use-invoices/use-invoices.ts +++ b/libs/domains/organizations/feature/src/lib/hooks/use-invoices/use-invoices.ts @@ -3,11 +3,15 @@ import { queries } from '@qovery/state/util-queries' export interface UseInvoicesProps { organizationId: string + enabled?: boolean + suspense?: boolean } -export function useInvoices({ organizationId }: UseInvoicesProps) { +export function useInvoices({ organizationId, enabled = true, suspense = false }: UseInvoicesProps) { return useQuery({ ...queries.organizations.invoices({ organizationId }), + enabled, + suspense, }) } diff --git a/libs/domains/organizations/feature/src/lib/settings-billing-summary/invoices-list-feature/invoices-list-feature.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-billing-summary/invoices-list-feature/invoices-list-feature.spec.tsx new file mode 100644 index 00000000000..e9479e6fc97 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-billing-summary/invoices-list-feature/invoices-list-feature.spec.tsx @@ -0,0 +1,152 @@ +import { type Invoice, InvoiceStatusEnum } from 'qovery-typescript-axios' +import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' +import * as invoiceUrlHooks from '../../hooks/use-invoice-url/use-invoice-url' +import * as invoiceHooks from '../../hooks/use-invoices/use-invoices' +import InvoicesListFeature, { InvoicesList, type InvoicesListProps, getListOfYears } from './invoices-list-feature' + +const useInvoicesSpy = jest.spyOn(invoiceHooks, 'useInvoices') +const useInvoiceUrlSpy = jest.spyOn(invoiceUrlHooks, 'useInvoiceUrl') + +jest.mock('@tanstack/react-router', () => ({ + ...jest.requireActual('@tanstack/react-router'), + useParams: () => ({ organizationId: '1' }), +})) + +const invoicesMock: Invoice[] = [ + { + status: InvoiceStatusEnum.UNKNOWN, + currency_code: 'EUR', + created_at: '2018-01-01T00:00:00.000Z', + id: '1', + total: 100, + total_in_cents: 10000, + }, + { + status: InvoiceStatusEnum.UNKNOWN, + currency_code: 'EUR', + created_at: '2021-01-01T00:00:00.000Z', + id: '2', + total: 100, + total_in_cents: 10000, + }, + { + status: InvoiceStatusEnum.UNKNOWN, + currency_code: 'EUR', + created_at: '2021-01-01T00:00:00.000Z', + id: '22', + total: 100, + total_in_cents: 10000, + }, + { + status: InvoiceStatusEnum.UNKNOWN, + currency_code: 'EUR', + created_at: '2020-01-01T00:00:00.000Z', + id: '3', + total: 100, + total_in_cents: 10000, + }, + { + status: InvoiceStatusEnum.UNKNOWN, + currency_code: 'EUR', + created_at: '2023-01-01T00:00:00.000Z', + id: '4', + total: 100, + total_in_cents: 10000, + }, +] + +const listProps: InvoicesListProps = { + onFilterByYear: jest.fn(), + yearsForSorting: [ + { + label: '2021', + value: '2021', + }, + { + label: '2018', + value: '2018', + }, + ], + invoices: invoicesMock.slice(0, 3), + downloadOne: jest.fn(), +} + +describe('InvoicesList', () => { + it('should render successfully', () => { + const { baseElement } = renderWithProviders() + expect(baseElement).toBeTruthy() + }) + + it('should print three rows', () => { + renderWithProviders() + expect(screen.getAllByTestId('download-invoice-btn')).toHaveLength(3) + }) + + it('should have two options in years', () => { + renderWithProviders() + screen.getByRole('option', { name: '2021' }) + screen.getByRole('option', { name: '2018' }) + }) + + it('should call onFilterByYear on select change', async () => { + const { userEvent } = renderWithProviders() + const select = screen.getByTestId('year-select') + + await userEvent.selectOptions(select, '2018') + + expect(listProps.onFilterByYear).toHaveBeenCalledWith('2018') + }) + + it('should render empty state when there are no invoices', () => { + renderWithProviders() + screen.getByText("You don't have any invoices yet.") + }) +}) + +describe('InvoicesListFeature', () => { + const mutateAsyncMock = jest.fn() + + beforeEach(() => { + mutateAsyncMock.mockReset() + useInvoicesSpy.mockReturnValue({ + data: invoicesMock, + }) + useInvoiceUrlSpy.mockReturnValue({ + mutateAsync: mutateAsyncMock, + }) + }) + + it('should render successfully', () => { + const { baseElement } = renderWithProviders() + expect(baseElement).toBeTruthy() + }) + + it('should dispatch fetchInvoices', () => { + renderWithProviders() + expect(useInvoicesSpy).toHaveBeenCalledWith({ + organizationId: '1', + suspense: true, + }) + }) + + it('should download invoice', async () => { + const linkClickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) + const { userEvent } = renderWithProviders() + const button = screen.getAllByTestId('download-invoice-btn')[0] + + await userEvent.click(button) + + await waitFor(() => { + expect(mutateAsyncMock).toHaveBeenCalledWith({ organizationId: '1', invoiceId: '1' }) + }) + + linkClickSpy.mockRestore() + }) +}) + +describe('getListOfYears', () => { + it('should return an array of years', () => { + const result = getListOfYears(invoicesMock) + expect(result).toEqual([2023, 2021, 2020, 2018]) + }) +}) diff --git a/libs/domains/organizations/feature/src/lib/settings-billing-summary/invoices-list-feature/invoices-list-feature.tsx b/libs/domains/organizations/feature/src/lib/settings-billing-summary/invoices-list-feature/invoices-list-feature.tsx new file mode 100644 index 00000000000..69867c49b19 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-billing-summary/invoices-list-feature/invoices-list-feature.tsx @@ -0,0 +1,227 @@ +import { useParams } from '@tanstack/react-router' +import { + type FilterFn, + createColumnHelper, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + useReactTable, +} from '@tanstack/react-table' +import { type Invoice } from 'qovery-typescript-axios' +import { useEffect, useMemo, useState } from 'react' +import { type Value } from '@qovery/shared/interfaces' +import { Icon, InputSelectSmall, TableFilter, TablePrimitives } from '@qovery/shared/ui' +import { upperCaseFirstLetter } from '@qovery/shared/util-js' +import { useInvoiceUrl } from '../../hooks/use-invoice-url/use-invoice-url' +import { useInvoices } from '../../hooks/use-invoices/use-invoices' +import TableRowInvoice from './table-row-invoice/table-row-invoice' + +const { Table } = TablePrimitives +const COLUMN_SIZES = [30, 30, 30, 10] +const columnHelper = createColumnHelper() +const formatStatusLabel = (status: string) => upperCaseFirstLetter(status.toLowerCase()).replace(/_/g, ' ') + +const statusFilter: FilterFn = (row, columnId, filterValue) => { + if (!Array.isArray(filterValue) || filterValue.length === 0) return true + return filterValue.includes(row.getValue(columnId)) +} + +statusFilter.autoRemove = (value) => !value?.length + +export interface InvoicesListProps { + invoices?: Invoice[] + downloadOne?: (invoiceId: string) => void + yearsForSorting?: Value[] + onFilterByYear?: (year?: string) => void + idOfInvoiceToDownload?: string +} + +export function InvoicesList(props: InvoicesListProps) { + const hasInvoices = (props.invoices?.length ?? 0) > 0 + const columns = useMemo( + () => [ + columnHelper.accessor('created_at', { + header: 'Date', + enableColumnFilter: false, + size: COLUMN_SIZES[0], + }), + columnHelper.accessor('status', { + header: 'Status', + enableColumnFilter: true, + filterFn: statusFilter, + size: COLUMN_SIZES[1], + meta: { + customFacetEntry({ value, count }) { + return ( + <> + {formatStatusLabel(String(value))} + {count} + + ) + }, + customFilterValue({ filterValue }) { + const values = Array.isArray(filterValue) ? filterValue : [] + return values.length ? values.map((value) => formatStatusLabel(String(value))).join(', ') : 'Status' + }, + }, + }), + columnHelper.accessor('total_in_cents', { + header: 'Charge', + enableColumnFilter: false, + size: COLUMN_SIZES[2], + }), + columnHelper.display({ + id: 'download', + header: '', + enableColumnFilter: false, + size: COLUMN_SIZES[3], + }), + ], + [] + ) + + const table = useReactTable({ + data: props.invoices ?? [], + columns, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + // https://github.com/TanStack/table/discussions/3192#discussioncomment-6458134 + defaultColumn: { + minSize: 0, + size: Number.MAX_SAFE_INTEGER, + maxSize: Number.MAX_SAFE_INTEGER, + }, + }) + + return ( +
+
+

Invoices

+ {hasInvoices ? ( +
+ { + props.onFilterByYear && props.onFilterByYear(value) + }} + /> +
+ ) : null} +
+ {hasInvoices ? ( + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : header.column.getCanFilter() ? ( + + ) : ( + flexRender(header.column.columnDef.header, header.getContext()) + )} + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + ))} + + + ) : ( +
+ +

You don't have any invoices yet.

+
+ )} +
+ ) +} + +export const getListOfYears = (invoices: Invoice[]) => { + const years = invoices.map((invoice) => new Date(invoice.created_at).getFullYear()) + return [...new Set(years)].sort((a, b) => b - a) +} + +export function InvoicesListFeature() { + const { organizationId = '' } = useParams({ strict: false }) + const [yearsFilterOptions, setYearsFilterOptions] = useState([]) + const [idOfInvoiceToDownload, setIdOfInvoiceToDownload] = useState(undefined) + const { data: dataInvoices = [] } = useInvoices({ organizationId, suspense: true }) + const [invoices, setInvoices] = useState(dataInvoices) + const { mutateAsync: mutateAsyncInvoice } = useInvoiceUrl() + + const downloadOne = async (invoiceId: string) => { + if (organizationId && invoiceId) { + setIdOfInvoiceToDownload(invoiceId) + + try { + const invoiceUrl = await mutateAsyncInvoice({ organizationId, invoiceId }) + if (invoiceUrl?.url) { + const link = document.createElement('a') + link.href = invoiceUrl.url + link.download = invoiceUrl.url.substring(invoiceUrl.url.lastIndexOf('/') + 1) + link.click() + setIdOfInvoiceToDownload(undefined) + } + } catch (error) { + console.error(error) + } + } + } + + useEffect(() => { + setInvoices(dataInvoices) + const years = getListOfYears(dataInvoices) + setYearsFilterOptions([ + { label: 'All', value: '' }, + ...years.map((year) => ({ label: `${year}`, value: `${year}` })), + ]) + }, [dataInvoices]) + + const filterByYear = (year?: string) => { + if (!year) { + setInvoices(dataInvoices) + return + } + + const filteredInvoices = dataInvoices.filter((invoice) => { + const invoiceYear = new Date(invoice.created_at).getFullYear() + return invoiceYear === parseInt(year, 10) + }) + setInvoices(filteredInvoices) + } + + return ( + + ) +} + +export default InvoicesListFeature diff --git a/libs/domains/organizations/feature/src/lib/settings-billing-summary/invoices-list-feature/table-row-invoice/table-row-invoice.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-billing-summary/invoices-list-feature/table-row-invoice/table-row-invoice.spec.tsx new file mode 100644 index 00000000000..853563b7416 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-billing-summary/invoices-list-feature/table-row-invoice/table-row-invoice.spec.tsx @@ -0,0 +1,60 @@ +import { type Invoice, InvoiceStatusEnum } from 'qovery-typescript-axios' +import { TablePrimitives } from '@qovery/shared/ui' +import { dateMediumLocalFormat } from '@qovery/shared/util-dates' +import { costToHuman } from '@qovery/shared/util-js' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { TableRowInvoice, type TableRowInvoiceProps } from './table-row-invoice' + +let props: TableRowInvoiceProps + +const { Table } = TablePrimitives + +const renderRow = (rowProps: TableRowInvoiceProps) => + renderWithProviders( + + + + + + ) + +const invoice: Invoice = { + status: InvoiceStatusEnum.UNKNOWN, + currency_code: 'EUR', + created_at: '2021-01-01T00:00:00.000Z', + id: '22', + total: 100, + total_in_cents: 10000, +} + +beforeEach(() => { + props = { + downloadInvoice: jest.fn(), + data: invoice, + } +}) + +describe('TableRowInvoice', () => { + it('should render successfully', () => { + const { baseElement } = renderRow(props) + expect(baseElement).toBeTruthy() + }) + + it('should render the different cells correctly', () => { + renderRow(props) + + screen.getByText(dateMediumLocalFormat(invoice.created_at)) + screen.getByText(String(invoice.status).replace('_', ' ')) + screen.getByText(costToHuman(invoice.total_in_cents / 100, invoice.currency_code)) + screen.getByTestId('download-invoice-btn') + }) + + it('should call the downloadInvoice function when clicking on the download button', async () => { + const { userEvent } = renderRow(props) + const button = screen.getByTestId('download-invoice-btn') + + await userEvent.click(button) + + expect(props.downloadInvoice).toHaveBeenCalledWith(invoice.id) + }) +}) diff --git a/libs/domains/organizations/feature/src/lib/settings-billing-summary/invoices-list-feature/table-row-invoice/table-row-invoice.tsx b/libs/domains/organizations/feature/src/lib/settings-billing-summary/invoices-list-feature/table-row-invoice/table-row-invoice.tsx new file mode 100644 index 00000000000..e5fc1a0a1a8 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-billing-summary/invoices-list-feature/table-row-invoice/table-row-invoice.tsx @@ -0,0 +1,76 @@ +import { type Invoice, InvoiceStatusEnum } from 'qovery-typescript-axios' +import { match } from 'ts-pattern' +import { Badge, Button, Icon, TablePrimitives } from '@qovery/shared/ui' +import { dateMediumLocalFormat } from '@qovery/shared/util-dates' +import { costToHuman } from '@qovery/shared/util-js' + +const { Table } = TablePrimitives + +export interface TableRowInvoiceProps { + data: Invoice + isLoading?: boolean + downloadInvoice?: (invoiceId: string) => void + columnSizes?: number[] +} + +export function TableRowInvoice(props: TableRowInvoiceProps) { + const { data, downloadInvoice, isLoading, columnSizes = [30, 30, 30, 10] } = props + + const statusLabel = data.status?.replace('_', ' ') ?? '' + const badge = match(data.status) + .with(InvoiceStatusEnum.PAID, () => ( + + {statusLabel} + + )) + .with( + InvoiceStatusEnum.NOT_PAID, + InvoiceStatusEnum.PENDING, + InvoiceStatusEnum.POSTED, + InvoiceStatusEnum.PAYMENT_DUE, + () => ( + + {statusLabel} + + ) + ) + .with(InvoiceStatusEnum.UNKNOWN, InvoiceStatusEnum.VOIDED, () => ( + + {statusLabel} + + )) + .exhaustive() + + return ( + + + {dateMediumLocalFormat(data.created_at)} + + + {badge} + + + {costToHuman(data.total_in_cents / 100, data.currency_code)} + + + + + + ) +} + +export default TableRowInvoice diff --git a/libs/pages/settings/src/lib/ui/page-organization-billing-summary/plan-selection-modal/plan-selection-modal.tsx b/libs/domains/organizations/feature/src/lib/settings-billing-summary/plan-selection-modal-feature/plan-selection-modal-feature.tsx similarity index 51% rename from libs/pages/settings/src/lib/ui/page-organization-billing-summary/plan-selection-modal/plan-selection-modal.tsx rename to libs/domains/organizations/feature/src/lib/settings-billing-summary/plan-selection-modal-feature/plan-selection-modal-feature.tsx index c594d1fae53..3a0297473b0 100644 --- a/libs/pages/settings/src/lib/ui/page-organization-billing-summary/plan-selection-modal/plan-selection-modal.tsx +++ b/libs/domains/organizations/feature/src/lib/settings-billing-summary/plan-selection-modal-feature/plan-selection-modal-feature.tsx @@ -1,8 +1,13 @@ import { PlanEnum } from 'qovery-typescript-axios' +import { useState } from 'react' import { type FormEventHandler } from 'react' +import { FormProvider, useForm } from 'react-hook-form' import { Controller, useFormContext } from 'react-hook-form' +import { P, match } from 'ts-pattern' import { Button, RadioGroup } from '@qovery/shared/ui' +import { is2025Plan } from '@qovery/shared/util-js' import { twMerge } from '@qovery/shared/util-js' +import { useChangePlan } from '../../hooks/use-change-plan/use-change-plan' // All 2025 plans are selectable const SELECTABLE_PLANS = [ @@ -29,8 +34,8 @@ export function PlanSelectionModal(props: PlanSelectionModalProps) { return (
-

Change Plan (Qovery only)

-

+

Change Plan (Qovery only)

+

Select the plan you want to change to. Note: this modal is only visible to Qovery admins

@@ -47,16 +52,16 @@ export function PlanSelectionModal(props: PlanSelectionModalProps) {
diff --git a/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/invoices-list-feature/invoices-list-feature.spec.tsx b/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/invoices-list-feature/invoices-list-feature.spec.tsx deleted file mode 100644 index 9d17cc231bc..00000000000 --- a/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/invoices-list-feature/invoices-list-feature.spec.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { type Invoice, InvoiceStatusEnum } from 'qovery-typescript-axios' -import * as organizationsDomain from '@qovery/domains/organizations/feature' -import { renderWithProviders, screen } from '@qovery/shared/util-tests' -import InvoicesListFeature, { getListOfYears } from './invoices-list-feature' - -import SpyInstance = jest.SpyInstance - -const useInvoicesSpy: SpyInstance = jest.spyOn(organizationsDomain, 'useInvoices') -const useInvoiceUrlSpy: SpyInstance = jest.spyOn(organizationsDomain, 'useInvoiceUrl') - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ organizationId: '1' }), -})) - -const invoicesMock: Invoice[] = [ - { - status: InvoiceStatusEnum.UNKNOWN, - currency_code: 'EUR', - created_at: '2018-01-01T00:00:00.000Z', - id: '1', - total: 100, - total_in_cents: 10000, - }, - { - status: InvoiceStatusEnum.UNKNOWN, - currency_code: 'EUR', - created_at: '2021-01-01T00:00:00.000Z', - id: '2', - total: 100, - total_in_cents: 10000, - }, - { - status: InvoiceStatusEnum.UNKNOWN, - currency_code: 'EUR', - created_at: '2021-01-01T00:00:00.000Z', - id: '22', - total: 100, - total_in_cents: 10000, - }, - { - status: InvoiceStatusEnum.UNKNOWN, - currency_code: 'EUR', - created_at: '2020-01-01T00:00:00.000Z', - id: '3', - total: 100, - total_in_cents: 10000, - }, - { - status: InvoiceStatusEnum.UNKNOWN, - currency_code: 'EUR', - created_at: '2023-01-01T00:00:00.000Z', - id: '4', - total: 100, - total_in_cents: 10000, - }, -] - -describe('InvoicesListFeature', () => { - beforeEach(() => { - useInvoicesSpy.mockReturnValue({ - data: invoicesMock, - }) - useInvoiceUrlSpy.mockReturnValue({ - mutateAsync: jest.fn(), - }) - }) - - it('should render successfully', () => { - const { baseElement } = renderWithProviders() - expect(baseElement).toBeTruthy() - }) - - it('should dispatch fetchInvoices', () => { - renderWithProviders() - expect(useInvoicesSpy).toHaveBeenCalledWith({ - organizationId: '1', - }) - }) - - it('should download invoice', async () => { - window.open = jest.fn((url: string, target: string) => ({})) - const { userEvent } = renderWithProviders() - const button = screen.getAllByTestId('download-invoice-btn')[0] - - await userEvent.click(button) - - expect(useInvoiceUrlSpy().mutateAsync).toHaveBeenCalledWith({ organizationId: '1', invoiceId: '1' }) - }) -}) - -describe('getListOfYears', () => { - it('should return an array of years', () => { - const result = getListOfYears(invoicesMock) - expect(result).toEqual([2023, 2021, 2020, 2018]) - }) -}) diff --git a/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/invoices-list-feature/invoices-list-feature.tsx b/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/invoices-list-feature/invoices-list-feature.tsx deleted file mode 100644 index 17c2949392f..00000000000 --- a/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/invoices-list-feature/invoices-list-feature.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { type Invoice } from 'qovery-typescript-axios' -import { useEffect, useState } from 'react' -import { useParams } from 'react-router-dom' -import { useInvoiceUrl, useInvoices } from '@qovery/domains/organizations/feature' -import { type Value } from '@qovery/shared/interfaces' -import InvoicesList from '../../../ui/page-organization-billing-summary/invoices-list/invoices-list' - -export const getListOfYears = (invoices: Invoice[]) => { - const years = invoices.map((invoice) => new Date(invoice.created_at).getFullYear()) - return [...new Set(years)].sort((a, b) => b - a) -} - -export function InvoicesListFeature() { - const { organizationId = '' } = useParams() - const [yearsFilterOptions, setYearsFilterOptions] = useState([]) - const [idOfInvoiceToDownload, setIdOfInvoiceToDownload] = useState(undefined) - const [invoices, setInvoices] = useState([]) - const { data: dataInvoices = [], isLoading: isLoadingDataInvoices } = useInvoices({ organizationId }) - const { mutateAsync: mutateAsyncInvoice } = useInvoiceUrl() - - const downloadOne = async (invoiceId: string) => { - if (organizationId && invoiceId) { - setIdOfInvoiceToDownload(invoiceId) - - try { - const invoiceUrl = await mutateAsyncInvoice({ organizationId, invoiceId }) - if (invoiceUrl?.url) { - const link = document.createElement('a') - link.href = invoiceUrl.url - link.download = invoiceUrl.url.substring(invoiceUrl.url.lastIndexOf('/') + 1) - link.click() - setIdOfInvoiceToDownload(undefined) - } - } catch (error) { - console.error(error) - } - } - } - - useEffect(() => { - if (dataInvoices.length > 0) { - setInvoices(dataInvoices) - const years = getListOfYears(dataInvoices) - setYearsFilterOptions([ - { label: 'All', value: '' }, - ...years.map((year) => ({ label: `${year}`, value: `${year}` })), - ]) - } - }, [dataInvoices]) - - const filterByYear = (year?: string) => { - if (invoices.length > 0) { - if (!year) return setInvoices(dataInvoices) - - const filteredInvoices = dataInvoices.filter((invoice) => { - const invoiceYear = new Date(invoice.created_at).getFullYear() - return invoiceYear === parseInt(year, 10) - }) - setInvoices(filteredInvoices) - } - } - - return ( - - ) -} - -export default InvoicesListFeature diff --git a/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/page-organization-billing-summary-feature.spec.tsx b/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/page-organization-billing-summary-feature.spec.tsx deleted file mode 100644 index 104cbd842fb..00000000000 --- a/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/page-organization-billing-summary-feature.spec.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { IntercomProvider } from 'react-use-intercom' -import * as organizationsDomain from '@qovery/domains/organizations/feature' -import { renderWithProviders } from '@qovery/shared/util-tests' -import PageOrganizationBillingSummaryFeature from './page-organization-billing-summary-feature' - -import SpyInstance = jest.SpyInstance - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ organizationId: '1' }), -})) - -describe('PageOrganizationBillingSummaryFeature', () => { - it('should render successfully', () => { - const { baseElement } = renderWithProviders( - - - - ) - expect(baseElement).toBeTruthy() - }) - - it('should fetch credit card, clusters and organization costs', () => { - const useCreditCardsSpy: SpyInstance = jest.spyOn(organizationsDomain, 'useCreditCards') - const useCurrentCostSpy: SpyInstance = jest.spyOn(organizationsDomain, 'useCurrentCost') - - renderWithProviders( - - - - ) - - expect(useCreditCardsSpy).toHaveBeenCalled() - expect(useCurrentCostSpy).toHaveBeenCalled() - }) -}) diff --git a/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/page-organization-billing-summary-feature.tsx b/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/page-organization-billing-summary-feature.tsx deleted file mode 100644 index 93060984d54..00000000000 --- a/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/page-organization-billing-summary-feature.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useNavigate, useParams } from 'react-router-dom' -import { useCreditCards, useCurrentCost } from '@qovery/domains/organizations/feature' -import { AddCreditCardModalFeature } from '@qovery/shared/console-shared' -import { useUserRole } from '@qovery/shared/iam/feature' -import { SETTINGS_DANGER_ZONE_URL, SETTINGS_URL } from '@qovery/shared/routes' -import { useModal } from '@qovery/shared/ui' -import { useDocumentTitle, useSupportChat } from '@qovery/shared/util-hooks' -import PageOrganizationBillingSummary from '../../ui/page-organization-billing-summary/page-organization-billing-summary' -import PlanSelectionModalFeature from './plan-selection-modal-feature/plan-selection-modal-feature' -import PromoCodeModalFeature from './promo-code-modal-feature/promo-code-modal-feature' -import ShowUsageModalFeature from './show-usage-modal-feature/show-usage-modal-feature' - -export function PageOrganizationBillingSummaryFeature() { - useDocumentTitle('Billing summary - Organization settings') - - const { openModal, closeModal } = useModal() - - const { organizationId = '' } = useParams() - const navigate = useNavigate() - - const { data: creditCards = [], isLoading: isLoadingCreditCards } = useCreditCards({ organizationId }) - const { data: currentCost } = useCurrentCost({ organizationId }) - const { showChat } = useSupportChat() - const { isQoveryAdminUser } = useUserRole() - - const openPromoCodeModal = () => { - openModal({ - content: , - }) - } - - const openShowUsageModal = () => { - openModal({ - content: currentCost && , - }) - } - - const openPlanSelectionModal = () => { - openModal({ - content: ( - - ), - }) - } - - const handleChangePlanClick = () => { - if (isQoveryAdminUser) { - openPlanSelectionModal() - } else { - showChat() - } - } - - const handleCancelTrialClick = () => { - navigate(SETTINGS_URL(organizationId) + SETTINGS_DANGER_ZONE_URL) - } - - const handleAddCreditCardClick = () => { - openModal({ - content: , - }) - } - - return ( - 0} - onPromoCodeClick={openPromoCodeModal} - onShowUsageClick={openShowUsageModal} - onChangePlanClick={handleChangePlanClick} - onCancelTrialClick={handleCancelTrialClick} - onAddCreditCardClick={handleAddCreditCardClick} - /> - ) -} - -export default PageOrganizationBillingSummaryFeature diff --git a/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/plan-selection-modal-feature/plan-selection-modal-feature.tsx b/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/plan-selection-modal-feature/plan-selection-modal-feature.tsx deleted file mode 100644 index e97bc584482..00000000000 --- a/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/plan-selection-modal-feature/plan-selection-modal-feature.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { PlanEnum } from 'qovery-typescript-axios' -import { useState } from 'react' -import { FormProvider, useForm } from 'react-hook-form' -import { P, match } from 'ts-pattern' -import { useChangePlan } from '@qovery/domains/organizations/feature' -import { is2025Plan } from '@qovery/shared/util-js' -import PlanSelectionModal from '../../../ui/page-organization-billing-summary/plan-selection-modal/plan-selection-modal' - -export interface PlanSelectionModalFeatureProps { - organizationId?: string - closeModal: () => void - currentPlan?: string -} - -/** - * Normalizes the current plan to match the 2025 plan enum values - * Only returns a value if the current plan is already a 2025 plan - * Returns undefined for legacy plans to avoid pre-selection - */ -function normalizePlanSelection(currentPlan?: string): PlanEnum | undefined { - if (!is2025Plan(currentPlan)) { - return undefined - } - - return match(currentPlan?.toUpperCase()) - .with(P.string.includes('USER'), () => PlanEnum.USER_2025) - .with(P.string.includes('TEAM'), () => PlanEnum.TEAM_2025) - .with(P.string.includes('BUSINESS'), () => PlanEnum.BUSINESS_2025) - .with(P.string.includes('ENTERPRISE'), () => PlanEnum.ENTERPRISE_2025) - .otherwise(() => undefined) -} - -export function PlanSelectionModalFeature({ organizationId, closeModal, currentPlan }: PlanSelectionModalFeatureProps) { - const normalizedPlan = normalizePlanSelection(currentPlan) - - const methods = useForm<{ plan: PlanEnum }>({ defaultValues: { plan: normalizedPlan as PlanEnum }, mode: 'all' }) - const [isSubmitting, setIsSubmitting] = useState(false) - const { mutateAsync: changePlan } = useChangePlan() - - const onSubmit = methods.handleSubmit(async (data) => { - if (organizationId && data.plan) { - setIsSubmitting(true) - - try { - await changePlan({ organizationId, plan: data.plan }) - closeModal() - } catch (error) { - console.error(error) - } - - setIsSubmitting(false) - } - }) - - return ( - - - - ) -} - -export default PlanSelectionModalFeature diff --git a/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/promo-code-modal-feature/promo-code-modal-feature.spec.tsx b/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/promo-code-modal-feature/promo-code-modal-feature.spec.tsx deleted file mode 100644 index d7ec591e353..00000000000 --- a/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/promo-code-modal-feature/promo-code-modal-feature.spec.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import * as organizationsDomain from '@qovery/domains/organizations/feature' -import { renderWithProviders, screen } from '@qovery/shared/util-tests' -import PromoCodeModalFeature, { type PromocodeModalFeatureProps } from './promo-code-modal-feature' - -const useAddCreditCodeSpy = jest.spyOn(organizationsDomain, 'useAddCreditCode') as jest.Mock - -const props: PromocodeModalFeatureProps = { - closeModal: jest.fn(), - organizationId: '1', -} - -describe('PromoCodeModalFeature', () => { - beforeEach(() => { - useAddCreditCodeSpy.mockReturnValue({ - mutateAsync: jest.fn(), - }) - }) - - it('should render successfully', () => { - const { baseElement } = renderWithProviders() - expect(baseElement).toBeTruthy() - }) - - it('should useAddCreditCode with good params', async () => { - const { userEvent } = renderWithProviders() - - const input = screen.getByLabelText('Promo code') - await userEvent.type(input, 'test') - - const button = screen.getByTestId('submit-button') - await userEvent.click(button) - - expect(useAddCreditCodeSpy().mutateAsync).toHaveBeenCalledWith({ organizationId: '1', code: 'test' }) - }) -}) diff --git a/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/promo-code-modal-feature/promo-code-modal-feature.tsx b/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/promo-code-modal-feature/promo-code-modal-feature.tsx deleted file mode 100644 index d288b046fa2..00000000000 --- a/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/promo-code-modal-feature/promo-code-modal-feature.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useState } from 'react' -import { FormProvider, useForm } from 'react-hook-form' -import { useAddCreditCode } from '@qovery/domains/organizations/feature' -import PromoCodeModal from '../../../ui/page-organization-billing-summary/promo-code-modal/promo-code-modal' - -export interface PromocodeModalFeatureProps { - organizationId?: string - closeModal: () => void -} - -export function PromoCodeModalFeature({ organizationId, closeModal }: PromocodeModalFeatureProps) { - const methods = useForm<{ code: string }>({ defaultValues: { code: '' }, mode: 'all' }) - const [isSubmitting, setIsSubmitting] = useState(false) - const { mutateAsync: addCreditCode } = useAddCreditCode() - - const onSubmit = methods.handleSubmit(async (data) => { - if (organizationId && data.code) { - setIsSubmitting(true) - - try { - await addCreditCode({ organizationId, code: data.code }) - closeModal() - } catch (error) { - console.error(error) - } - - setIsSubmitting(false) - } - }) - - return ( - - - - ) -} - -export default PromoCodeModalFeature diff --git a/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/show-usage-modal-feature/show-usage-modal-feature.spec.tsx b/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/show-usage-modal-feature/show-usage-modal-feature.spec.tsx deleted file mode 100644 index 1b7b4ece63b..00000000000 --- a/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/show-usage-modal-feature/show-usage-modal-feature.spec.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { PlanEnum } from 'qovery-typescript-axios' -import * as organizationsDomain from '@qovery/domains/organizations/feature' -import { organizationFactoryMock } from '@qovery/shared/factories' -import { renderWithProviders, screen } from '@qovery/shared/util-tests' -import ShowUsageModalFeature, { type ShowUsageModalFeatureProps } from './show-usage-modal-feature' - -const useGenerateBillingUsageReportSpy = jest.spyOn(organizationsDomain, 'useGenerateBillingUsageReport') as jest.Mock -const useOrganizationSpy = jest.spyOn(organizationsDomain, 'useOrganization') as jest.Mock - -const props: ShowUsageModalFeatureProps = { - organizationId: '1', - currentCost: { - plan: PlanEnum.ENTERPRISE, - renewal_at: '2023-03-03', - remaining_trial_day: 1, - cost: { - total_in_cents: 10, - total: 2, - currency_code: 'USD', - }, - }, -} - -describe('ShowUsageModalFeature', () => { - beforeEach(() => { - useGenerateBillingUsageReportSpy.mockReturnValue({ - mutateAsync: jest.fn(() => ({ - report_url: 'http://example.com', - })), - isLoading: false, - }) - useOrganizationSpy.mockReturnValue({ - data: organizationFactoryMock(1)[0], - }) - }) - - it('should render successfully', () => { - const { baseElement } = renderWithProviders() - expect(baseElement).toBeTruthy() - }) - - it('should call useGenerateBillingUsageReport when onSubmit is called', async () => { - const { userEvent } = renderWithProviders() - const button = screen.getByRole('button', { name: /generate report/i }) - await userEvent.click(button) - - expect(useGenerateBillingUsageReportSpy).toHaveBeenCalled() - }) -}) diff --git a/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/show-usage-modal-feature/show-usage-modal-feature.tsx b/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/show-usage-modal-feature/show-usage-modal-feature.tsx deleted file mode 100644 index ec260a53e3a..00000000000 --- a/libs/pages/settings/src/lib/feature/page-organization-billing-summary-feature/show-usage-modal-feature/show-usage-modal-feature.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { type OrganizationCurrentCost } from 'qovery-typescript-axios' -import { FormProvider, useForm } from 'react-hook-form' -import { useGenerateBillingUsageReport, useOrganization } from '@qovery/domains/organizations/feature' -import { useModal } from '@qovery/shared/ui' -import ShowUsageModal from '../../../ui/page-organization-billing-summary/show-usage-modal/show-usage-modal' -import ShowUsageValueModal from '../../../ui/page-organization-billing-summary/show-usage-value-modal/show-usage-value-modal' - -export interface ShowUsageModalFeatureProps { - organizationId: string - currentCost: OrganizationCurrentCost -} - -export function ShowUsageModalFeature({ organizationId, currentCost }: ShowUsageModalFeatureProps) { - const methods = useForm<{ expires: number; report_period: string }>({ - defaultValues: { - expires: 24, - }, - mode: 'all', - }) - - const { data: organization } = useOrganization({ organizationId }) - const { mutateAsync: usageBillingReport, isLoading: isLoadingUsageBillingReport } = useGenerateBillingUsageReport() - const { openModal, closeModal } = useModal() - - const onSubmit = methods.handleSubmit(async (data) => { - if (!organization) return - - try { - const selectedReportPeriod = JSON.parse(data.report_period) - - const res = await usageBillingReport({ - organizationId, - usageReportRequest: { - from: new Date(new Date(selectedReportPeriod.from).getTime() + 24 * 60 * 60 * 1000).toISOString(), - to: selectedReportPeriod.to - ? new Date(new Date(selectedReportPeriod.to).getTime() + 24 * 60 * 60 * 1000).toISOString() - : new Date().toISOString(), - report_expiration_in_seconds: methods.getValues('expires') * 60 * 60, // hours to seconds - }, - }) - openModal({ - content: ( - - ), - }) - } catch (error) { - console.error(error) - } - }) - - return ( - - - - ) -} - -export default ShowUsageModalFeature diff --git a/libs/pages/settings/src/lib/ui/page-organization-billing-summary/invoices-list/invoices-list.spec.tsx b/libs/pages/settings/src/lib/ui/page-organization-billing-summary/invoices-list/invoices-list.spec.tsx deleted file mode 100644 index 72b5cf8fa38..00000000000 --- a/libs/pages/settings/src/lib/ui/page-organization-billing-summary/invoices-list/invoices-list.spec.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { act, fireEvent, getAllByTestId, getByRole, getByTestId, render } from '__tests__/utils/setup-jest' -import { type Invoice, InvoiceStatusEnum } from 'qovery-typescript-axios' -import InvoicesList, { type InvoicesListProps } from './invoices-list' - -const invoices: Invoice[] = [ - { - status: InvoiceStatusEnum.UNKNOWN, - currency_code: 'EUR', - created_at: '2018-01-01T00:00:00.000Z', - id: '1', - total: 100, - total_in_cents: 10000, - }, - { - status: InvoiceStatusEnum.UNKNOWN, - currency_code: 'EUR', - created_at: '2021-01-01T00:00:00.000Z', - id: '2', - total: 100, - total_in_cents: 10000, - }, - { - status: InvoiceStatusEnum.UNKNOWN, - currency_code: 'EUR', - created_at: '2021-01-01T00:00:00.000Z', - id: '22', - total: 100, - total_in_cents: 10000, - }, -] - -const props: InvoicesListProps = { - onFilterByYear: jest.fn(), - yearsForSorting: [ - { - label: '2021', - value: '2021', - }, - { - label: '2018', - value: '2018', - }, - ], - invoicesLoading: false, - invoices: invoices, - downloadOne: jest.fn(), -} - -describe('InvoicesList', () => { - it('should render successfully', () => { - const { baseElement } = render() - expect(baseElement).toBeTruthy() - }) - - it('should print three rows', () => { - const { baseElement } = render() - expect(getAllByTestId(baseElement, 'download-invoice-btn')).toHaveLength(3) - }) - - it('should have two options in years', () => { - const { baseElement } = render() - getByRole(baseElement, 'option', { name: '2021' }) - getByRole(baseElement, 'option', { name: '2018' }) - }) - - it('should call onFilterByYear on select change', async () => { - const { baseElement } = render() - const select = getByTestId(baseElement, 'year-select') - - await act(() => { - fireEvent.change(select, { target: { value: '2018' } }) - }) - - expect(props.onFilterByYear).toHaveBeenCalledWith('2018') - }) - - it('should display one spinner', async () => { - const { baseElement } = render() - getByTestId(baseElement, 'spinner') - }) -}) diff --git a/libs/pages/settings/src/lib/ui/page-organization-billing-summary/invoices-list/invoices-list.tsx b/libs/pages/settings/src/lib/ui/page-organization-billing-summary/invoices-list/invoices-list.tsx deleted file mode 100644 index 9f85952ef20..00000000000 --- a/libs/pages/settings/src/lib/ui/page-organization-billing-summary/invoices-list/invoices-list.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { type Invoice } from 'qovery-typescript-axios' -import { useState } from 'react' -import { type Value } from '@qovery/shared/interfaces' -import { InputSelectSmall, LoaderSpinner, Table, type TableFilterProps, type TableHeadProps } from '@qovery/shared/ui' -import TableRowInvoice from './table-row-invoice/table-row-invoice' - -export interface InvoicesListProps { - invoices?: Invoice[] - invoicesLoading?: boolean - downloadOne?: (invoiceId: string) => void - yearsForSorting?: Value[] - onFilterByYear?: (year?: string) => void - idOfInvoiceToDownload?: string -} - -export function InvoicesList(props: InvoicesListProps) { - const dataHead: TableHeadProps[] = [ - { - title: 'Date', - }, - { - title: 'Status', - filter: [ - { - title: 'Filter by status', - key: 'status', - }, - ], - }, - { - title: 'Charge', - }, - ] - const [filter, setFilter] = useState([]) - const columnWidth = '30% 30% 30% 10%' - - return ( -
-
-

Invoices

-
- { - props.onFilterByYear && props.onFilterByYear(value) - }} - /> -
-
- -
- {props.invoicesLoading && ( -
- -
- )} - - {props.invoices?.map((invoice, index) => ( - - ))} -
-
-
- ) -} - -export default InvoicesList diff --git a/libs/pages/settings/src/lib/ui/page-organization-billing-summary/invoices-list/table-row-invoice/table-row-invoice.spec.tsx b/libs/pages/settings/src/lib/ui/page-organization-billing-summary/invoices-list/table-row-invoice/table-row-invoice.spec.tsx deleted file mode 100644 index ee9244ab54d..00000000000 --- a/libs/pages/settings/src/lib/ui/page-organization-billing-summary/invoices-list/table-row-invoice/table-row-invoice.spec.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { getByTestId, getByText, render } from '__tests__/utils/setup-jest' -import { InvoiceStatusEnum } from 'qovery-typescript-axios' -import { dateToFormat } from '@qovery/shared/util-dates' -import { TableRowInvoice, type TableRowInvoiceProps } from './table-row-invoice' - -let props: TableRowInvoiceProps - -beforeEach(() => { - props = { - downloadInvoice: jest.fn(), - data: { - status: InvoiceStatusEnum.UNKNOWN, - currency_code: 'EUR', - created_at: '2021-01-01T00:00:00.000Z', - id: '22', - total: 100, - total_in_cents: 10000, - }, - filter: [], - dataHead: [ - { - title: 'Date', - filter: [ - { - title: 'Filter by date', - key: 'created_at', - itemContentCustom: (item) => ( - {dateToFormat(item.created_at, 'MMM dd, Y')} - ), - }, - ], - }, - { - title: 'Status', - filter: [ - { - title: 'Filter by status', - key: 'status', - }, - ], - }, - { - title: 'Charge', - }, - ], - } -}) - -describe('TableRowDeployment', () => { - it('should render successfully', () => { - const { baseElement } = render() - expect(baseElement).toBeTruthy() - }) - - it('should render the different cells correctly', () => { - const { baseElement } = render() - - getByText(baseElement, 'Jan 1, 2021') - getByText(baseElement, 'UNKNOWN') - getByText(baseElement, '€100') - getByTestId(baseElement, 'download-invoice-btn') - }) - - it('should call the downloadInvoice function when clicking on the download button', () => { - const { baseElement } = render() - const button = getByTestId(baseElement, 'download-invoice-btn') - button.click() - expect(props.downloadInvoice).toHaveBeenCalled() - }) -}) diff --git a/libs/pages/settings/src/lib/ui/page-organization-billing-summary/invoices-list/table-row-invoice/table-row-invoice.tsx b/libs/pages/settings/src/lib/ui/page-organization-billing-summary/invoices-list/table-row-invoice/table-row-invoice.tsx deleted file mode 100644 index 21bc5c07353..00000000000 --- a/libs/pages/settings/src/lib/ui/page-organization-billing-summary/invoices-list/table-row-invoice/table-row-invoice.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { type Invoice, InvoiceStatusEnum } from 'qovery-typescript-axios' -import { match } from 'ts-pattern' -import { Badge, Button, Icon, type TableFilterProps, type TableHeadProps, TableRow } from '@qovery/shared/ui' -import { dateMediumLocalFormat } from '@qovery/shared/util-dates' -import { costToHuman } from '@qovery/shared/util-js' - -export interface TableRowInvoiceProps { - dataHead: TableHeadProps[] - data: Invoice - filter: TableFilterProps[] - columnsWidth?: string - isLoading?: boolean - index?: number - downloadInvoice?: (invoiceId: string) => void -} - -export function TableRowInvoice(props: TableRowInvoiceProps) { - const { dataHead, columnsWidth = `repeat(${dataHead.length},minmax(0,1fr))`, data, filter, downloadInvoice } = props - - const statusLabel = data.status.replace('_', ' ') - const badge = match(data.status) - .with(InvoiceStatusEnum.PAID, () => ( - - {statusLabel} - - )) - .with( - InvoiceStatusEnum.NOT_PAID, - InvoiceStatusEnum.PENDING, - InvoiceStatusEnum.POSTED, - InvoiceStatusEnum.PAYMENT_DUE, - () => ( - - {statusLabel} - - ) - ) - .with(InvoiceStatusEnum.UNKNOWN, InvoiceStatusEnum.VOIDED, () => ( - - {statusLabel} - - )) - .exhaustive() - - return ( - - <> -
{dateMediumLocalFormat(data.created_at)}
-
{badge}
-
- {costToHuman(data.total_in_cents / 100, data.currency_code)} -
-
- -
- -
- ) -} - -export default TableRowInvoice diff --git a/libs/pages/settings/src/lib/ui/page-organization-billing-summary/page-organization-billing-summary.spec.tsx b/libs/pages/settings/src/lib/ui/page-organization-billing-summary/page-organization-billing-summary.spec.tsx deleted file mode 100644 index 094f1d3247e..00000000000 --- a/libs/pages/settings/src/lib/ui/page-organization-billing-summary/page-organization-billing-summary.spec.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { PlanEnum } from 'qovery-typescript-axios' -import { creditCardsFactoryMock } from '@qovery/shared/factories' -import { renderWithProviders, screen } from '@qovery/shared/util-tests' -import PageOrganizationBillingSummary, { - type PageOrganizationBillingSummaryProps, -} from './page-organization-billing-summary' - -const props: PageOrganizationBillingSummaryProps = { - creditCard: creditCardsFactoryMock(1)[0], - creditCardLoading: false, - currentCost: { - plan: PlanEnum.ENTERPRISE, - cost: { total: 56000, currency_code: 'USD', total_in_cents: 5600000 }, - renewal_at: '2021-01-01', - }, - onPromoCodeClick: jest.fn(), - onShowUsageClick: jest.fn(), - onChangePlanClick: jest.fn(), -} - -describe('PageOrganizationBillingSummary', () => { - it('should render successfully', () => { - const { baseElement } = renderWithProviders() - expect(baseElement).toBeTruthy() - }) - - it('should display 6 boxes', () => { - renderWithProviders() - screen.getByText('Current plan') - screen.getByText('Enterprise plan (Legacy)') - - screen.getByText('Current bill') - screen.getByText('$56,000') - }) - - it('should say that no credit card was found', () => { - renderWithProviders() - screen.getByText('No credit card provided') - }) - - it('should say call show usage modal on click', async () => { - const { userEvent } = renderWithProviders() - const button = screen.getByText(/show usage/i) - await userEvent.click(button) - - expect(props.onShowUsageClick).toHaveBeenCalled() - }) - - it('should call onChangePlanClick on click on change plan', async () => { - const { userEvent } = renderWithProviders() - const button = screen.getByText('Change plan') - await userEvent.click(button) - - expect(props.onChangePlanClick).toHaveBeenCalled() - }) - - it('should say call onPromoCodeClick on click on add promo code', async () => { - const { userEvent } = renderWithProviders() - const button = screen.getByText(/promo code/i) - await userEvent.click(button) - - expect(props.onPromoCodeClick).toHaveBeenCalled() - }) - - it('should display not display the payment method box', () => { - props.currentCost = { - plan: PlanEnum.FREE, - renewal_at: '2021-01-01', - cost: { total: 56000, currency_code: 'USD', total_in_cents: 5600000 }, - } - - renderWithProviders() - screen.getByText('Current plan') - screen.getByText('Current bill') - expect(screen.queryByText('Payment method')).not.toBeInTheDocument() - }) -}) diff --git a/libs/pages/settings/src/lib/ui/page-organization-billing-summary/page-organization-billing-summary.tsx b/libs/pages/settings/src/lib/ui/page-organization-billing-summary/page-organization-billing-summary.tsx deleted file mode 100644 index 2902bde7347..00000000000 --- a/libs/pages/settings/src/lib/ui/page-organization-billing-summary/page-organization-billing-summary.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { format } from 'date-fns' -import { type CreditCard, type OrganizationCurrentCost, PlanEnum } from 'qovery-typescript-axios' -import { useMemo } from 'react' -import { type CardImages } from 'react-payment-inputs/images' -import { useParams } from 'react-router-dom' -import { useUserSignUp } from '@qovery/domains/users-sign-up/feature' -import { SETTINGS_BILLING_URL, SETTINGS_URL } from '@qovery/shared/routes' -import { - Button, - Callout, - ExternalLink, - Heading, - Icon, - Link, - Section, - Skeleton, - imagesCreditCart, -} from '@qovery/shared/ui' -import { dateToFormat } from '@qovery/shared/util-dates' -import { costToHuman, formatPlanDisplay, pluralize } from '@qovery/shared/util-js' -import InvoicesListFeature from '../../feature/page-organization-billing-summary-feature/invoices-list-feature/invoices-list-feature' - -export interface PageOrganizationBillingSummaryProps { - currentCost?: OrganizationCurrentCost - creditCard?: CreditCard - creditCardLoading?: boolean - hasCreditCard?: boolean - onPromoCodeClick?: () => void - onShowUsageClick?: () => void - onChangePlanClick?: () => void - onCancelTrialClick?: () => void - onAddCreditCardClick?: () => void -} - -// This function is used to get the billing recurrence word to display based on the renewal date. -// it's not so accurate, but it's a good enough approximation for now -function getBillingRecurrenceStr(renewalAt: string | null | undefined): string { - if (renewalAt === null || renewalAt === undefined) return 'month' - - const now = new Date() - const renewalDate = new Date(renewalAt) - // if the renewal date is in less than 1 month, we display "month" - - if (renewalDate.getTime() - now.getTime() > 30 * 24 * 60 * 60 * 1000) return 'year' - - return 'month' -} - -export function PageOrganizationBillingSummary(props: PageOrganizationBillingSummaryProps) { - const { organizationId = '' } = useParams() - const { data: userSignUp } = useUserSignUp() - - // Get the billing recurrence word to display based on the renewal date. - // It's not so accurate, but it's a good enough approximation for now - const billingRecurrence = getBillingRecurrenceStr(props.currentCost?.renewal_at) - const remainingTrialDay = props.currentCost?.remaining_trial_day ?? 0 - const showTrialCallout = remainingTrialDay !== undefined && remainingTrialDay > 0 && !props.creditCardLoading - const showErrorCallout = (props.hasCreditCard ?? Boolean(props.creditCard)) || userSignUp?.dx_auth - - // This function is used to get the trial start date based on the remaining trial days from the API - const trialStartDate = useMemo(() => { - const remainingTrialDayFromApi = props.currentCost?.remaining_trial_day - if (remainingTrialDayFromApi === undefined || remainingTrialDayFromApi === null) return null - - const trialDurationDays = 14 - const today = new Date() - today.setHours(0, 0, 0, 0) - - const daysUntilExpiration = remainingTrialDayFromApi + 1 - const expirationDate = new Date(today) - expirationDate.setDate(expirationDate.getDate() + daysUntilExpiration) - - const startDate = new Date(expirationDate) - startDate.setDate(startDate.getDate() - trialDurationDays) - startDate.setHours(0, 0, 0, 0) - - return startDate - }, [props.currentCost?.remaining_trial_day]) - - return ( -
-
- {showTrialCallout && ( - - - - {/* Add + 1 because Chargebee return 0 when the trial is ending today */} - {showErrorCallout - ? `Your free trial plan expires ${remainingTrialDay + 1} ${pluralize(remainingTrialDay + 1, 'day')} from now` - : `No credit card registered, your account will be blocked at the end your trial in ${remainingTrialDay + 1} ${pluralize(remainingTrialDay + 1, 'day')}`} - - {showErrorCallout ? ( - <> - You have contracted a free 14-days trial on{' '} - {trialStartDate ? format(trialStartDate, 'MMMM d, yyyy') : '...'}. At the end of this plan your user - subscription will start. You cancel your trial by deleting your organization. - - ) : ( - <>Add a payment method to avoid service interruption at the end of your trial. - )} - - - - )} -
- Plan details -
- - - -
-
- -
-
-
Current plan
-
- -
{formatPlanDisplay(props.currentCost?.plan)}
-
-
- - See details - -
-
-
Current bill
-
- -
- - {costToHuman(props.currentCost?.cost?.total || 0, props.currentCost?.cost?.currency_code || 'USD')} - {' '} - / {billingRecurrence} -
-
-
- {props.currentCost?.plan !== PlanEnum.FREE && ( -

- Next invoice:{' '} - - {props.currentCost?.renewal_at && dateToFormat(props.currentCost?.renewal_at, 'MMM dd, Y')} - -

- )} -
- - {props.currentCost?.plan !== PlanEnum.FREE && ( -
-
Payment method
-
- -
- {props.creditCard ? ( - <> - - - **** {props.creditCard?.last_digit} - - - ) : ( - No credit card provided - )} -
-
-
- - Edit payment - -
- )} -
- -
-
- ) -} - -export default PageOrganizationBillingSummary diff --git a/libs/pages/settings/src/lib/ui/page-organization-billing-summary/promo-code-modal/promo-code-modal.spec.tsx b/libs/pages/settings/src/lib/ui/page-organization-billing-summary/promo-code-modal/promo-code-modal.spec.tsx deleted file mode 100644 index b2656755db5..00000000000 --- a/libs/pages/settings/src/lib/ui/page-organization-billing-summary/promo-code-modal/promo-code-modal.spec.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { act, getByTestId, render } from '__tests__/utils/setup-jest' -import { wrapWithReactHookForm } from '__tests__/utils/wrap-with-react-hook-form' -import PromoCodeModal, { type PromoCodeModalProps } from './promo-code-modal' - -const props: PromoCodeModalProps = { - isSubmitting: false, - onClose: jest.fn(), - onSubmit: jest.fn((e) => e.preventDefault()), -} - -describe('PromocodeModal', () => { - it('should render successfully', () => { - const { baseElement } = render(wrapWithReactHookForm<{ code: string }>()) - expect(baseElement).toBeTruthy() - }) - - it('should show spinner', () => { - const { baseElement } = render( - wrapWithReactHookForm<{ code: string }>() - ) - getByTestId(baseElement, 'spinner') - }) - - it('should call on Submit', async () => { - const spy = jest.fn((e) => e.preventDefault()) - props.onSubmit = spy - - const { baseElement } = render( - wrapWithReactHookForm<{ code: string }>(, { - defaultValues: { code: 'test' }, - }) - ) - const button = getByTestId(baseElement, 'submit-button') - await act(() => { - button.click() - }) - - expect(spy).toHaveBeenCalled() - }) -}) diff --git a/libs/shared/ui/src/lib/components/inputs/input-select-small/input-select-small.tsx b/libs/shared/ui/src/lib/components/inputs/input-select-small/input-select-small.tsx index accd4531a6d..cccaaa89ce4 100644 --- a/libs/shared/ui/src/lib/components/inputs/input-select-small/input-select-small.tsx +++ b/libs/shared/ui/src/lib/components/inputs/input-select-small/input-select-small.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react' import { type Value } from '@qovery/shared/interfaces' import { twMerge } from '@qovery/shared/util-js' import Icon from '../../icon/icon' -import { IconAwesomeEnum } from '../../icon/icon-awesome.enum' export interface InputSelectSmallProps { name: string