From 3f9a726db6be063ba687af2163d6a9b826e46d9a Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Fri, 17 Oct 2025 14:11:14 +0300 Subject: [PATCH] refactor: move invoice logic into separate file/function (#10824) Extract calculateEstimateTotals and some utility functions. --- .../BillingInvoice/BillingInvoice.tsx | 49 +-- .../BillingInvoiceUsageRow.tsx | 32 +- .../calculateEstimateTotals.test.ts | 278 ++++++++++++++++++ .../BillingInvoice/calculateEstimateTotals.ts | 44 +++ 4 files changed, 350 insertions(+), 53 deletions(-) create mode 100644 frontend/src/component/admin/billing/BillingInvoices/BillingInvoice/calculateEstimateTotals.test.ts create mode 100644 frontend/src/component/admin/billing/BillingInvoices/BillingInvoice/calculateEstimateTotals.ts diff --git a/frontend/src/component/admin/billing/BillingInvoices/BillingInvoice/BillingInvoice.tsx b/frontend/src/component/admin/billing/BillingInvoices/BillingInvoice/BillingInvoice.tsx index 31fc2aabf8..42142ba55d 100644 --- a/frontend/src/component/admin/billing/BillingInvoices/BillingInvoice/BillingInvoice.tsx +++ b/frontend/src/component/admin/billing/BillingInvoices/BillingInvoice/BillingInvoice.tsx @@ -15,12 +15,10 @@ import { formatCurrency } from './formatCurrency.ts'; import { Badge } from 'component/common/Badge/Badge.tsx'; import { BillingInvoiceFooter } from './BillingInvoiceFooter/BillingInvoiceFooter.tsx'; import { StyledAmountCell, StyledSubgrid } from './BillingInvoice.styles.tsx'; -import type { - DetailedInvoicesSchemaInvoicesItem, - DetailedInvoicesLineSchema, -} from 'openapi'; +import type { DetailedInvoicesSchemaInvoicesItem } from 'openapi'; import { BillingInvoiceUsageRow } from './BillingInvoiceUsageRow/BillingInvoiceUsageRow.tsx'; import { BillingInvoiceMainRow } from './BillingInvoiceMainRow/BillingInvoiceMainRow.tsx'; +import { calculateEstimateTotals } from './calculateEstimateTotals.ts'; const StyledAccordion = styled(Accordion)(({ theme }) => ({ background: theme.palette.background.paper, @@ -103,49 +101,6 @@ const CardActions = styled('div')(({ theme }) => ({ padding: theme.spacing(0, 2, 2), })); -const calculateEstimateTotals = ( - status: string, - subtotal: number, - taxAmount: number, - totalAmount: number, - taxPercentage: number | undefined, - mainLines: DetailedInvoicesLineSchema[], - usageLines: DetailedInvoicesLineSchema[], -) => { - if (status !== 'estimate') { - return { - subtotal: subtotal, - taxAmount: taxAmount, - totalAmount: totalAmount, - }; - } - - const mainLinesTotal = mainLines.reduce( - (sum, line) => sum + (line.totalAmount || 0), - 0, - ); - - const usageLinesTotal = usageLines.reduce((sum, line) => { - const overage = - line.consumption && line.limit - ? Math.max(0, line.consumption - line.limit) - : 0; - return sum + overage * (line.unitPrice || 0); - }, 0); - - const calculatedSubtotal = mainLinesTotal + usageLinesTotal; - const calculatedTaxAmount = taxPercentage - ? calculatedSubtotal * (taxPercentage / 100) - : 0; - const calculatedTotalAmount = calculatedSubtotal + calculatedTaxAmount; - - return { - subtotal: calculatedSubtotal, - taxAmount: calculatedTaxAmount, - totalAmount: calculatedTotalAmount, - }; -}; - type BillingInvoiceProps = DetailedInvoicesSchemaInvoicesItem & Pick, 'defaultExpanded'>; diff --git a/frontend/src/component/admin/billing/BillingInvoices/BillingInvoice/BillingInvoiceUsageRow/BillingInvoiceUsageRow.tsx b/frontend/src/component/admin/billing/BillingInvoices/BillingInvoice/BillingInvoiceUsageRow/BillingInvoiceUsageRow.tsx index 16f2f2008e..a7ca07b51e 100644 --- a/frontend/src/component/admin/billing/BillingInvoices/BillingInvoice/BillingInvoiceUsageRow/BillingInvoiceUsageRow.tsx +++ b/frontend/src/component/admin/billing/BillingInvoices/BillingInvoice/BillingInvoiceUsageRow/BillingInvoiceUsageRow.tsx @@ -8,6 +8,25 @@ import { StyledDescriptionCell, } from '../BillingInvoice.styles.tsx'; +const hasValidUsageData = (consumption?: number, limit?: number): boolean => { + return Boolean(consumption && limit); +}; + +const calculateOverage = (consumption?: number, limit?: number): number => { + return hasValidUsageData(consumption, limit) + ? Math.max(0, consumption! - limit!) + : 0; +}; + +const calculateIncludedAmount = ( + consumption?: number, + limit?: number, +): number | undefined => { + return hasValidUsageData(consumption, limit) + ? Math.min(consumption!, limit!) + : consumption; +}; + const StyledCellWithIndicator = styled('div')(({ theme }) => ({ display: 'flex', alignItems: 'center', @@ -36,17 +55,18 @@ export const BillingInvoiceUsageRow = ({ : undefined; const isEstimate = invoiceStatus === 'estimate'; + const hasValidData = hasValidUsageData(consumption, limit); const overage = - isEstimate && consumption && limit - ? Math.max(0, consumption - limit) + isEstimate && hasValidData + ? calculateOverage(consumption, limit) : quantity; const includedAmount = - isEstimate && consumption && limit - ? Math.min(consumption, limit) + isEstimate && hasValidData + ? calculateIncludedAmount(consumption, limit) : consumption; const calculatedAmount = - isEstimate && unitPrice && consumption && limit - ? Math.max(0, consumption - limit) * unitPrice + isEstimate && unitPrice && hasValidData + ? calculateOverage(consumption, limit) * unitPrice : totalAmount; const hasAmount = calculatedAmount && calculatedAmount > 0; diff --git a/frontend/src/component/admin/billing/BillingInvoices/BillingInvoice/calculateEstimateTotals.test.ts b/frontend/src/component/admin/billing/BillingInvoices/BillingInvoice/calculateEstimateTotals.test.ts new file mode 100644 index 0000000000..f9d214acbd --- /dev/null +++ b/frontend/src/component/admin/billing/BillingInvoices/BillingInvoice/calculateEstimateTotals.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect } from 'vitest'; +import { calculateEstimateTotals } from './calculateEstimateTotals.ts'; +import type { DetailedInvoicesLineSchema } from 'openapi'; + +describe('calculateEstimateTotals', () => { + const createMainLine = ( + totalAmount: number, + ): DetailedInvoicesLineSchema => ({ + description: 'Test main line', + currency: 'USD', + lookupKey: 'test-key', + quantity: 1, + totalAmount, + }); + + const createUsageLine = ( + consumption: number, + limit: number, + unitPrice: number, + totalAmount: number = 0, + ): DetailedInvoicesLineSchema => ({ + description: 'Test usage line', + currency: 'USD', + lookupKey: 'test-usage-key', + quantity: 1, + totalAmount, + consumption, + limit, + unitPrice, + }); + + describe('non-estimate invoices', () => { + it('returns original values for non-estimate status', () => { + const result = calculateEstimateTotals( + 'invoiced', + 1000, + 150, + 1150, + 15, + [createMainLine(1000)], + [createUsageLine(100, 50, 2)], + ); + + expect(result).toEqual({ + subtotal: 1000, + taxAmount: 150, + totalAmount: 1150, + }); + }); + + it('returns original values for paid status', () => { + const result = calculateEstimateTotals( + 'paid', + 2000, + 300, + 2300, + 15, + [createMainLine(2000)], + [], + ); + + expect(result).toEqual({ + subtotal: 2000, + taxAmount: 300, + totalAmount: 2300, + }); + }); + }); + + describe('estimate invoices', () => { + it('calculates totals correctly with main lines only', () => { + const mainLines = [createMainLine(1000), createMainLine(500)]; + const result = calculateEstimateTotals( + 'estimate', + 0, + 0, + 0, + 15, + mainLines, + [], + ); + + expect(result).toEqual({ + subtotal: 1500, + taxAmount: 225, + totalAmount: 1725, + }); + }); + + it('calculates totals correctly with usage lines only', () => { + const usageLines = [ + createUsageLine(100, 50, 2), + createUsageLine(200, 150, 1.5), + ]; + const result = calculateEstimateTotals( + 'estimate', + 0, + 0, + 0, + 20, + [], + usageLines, + ); + + expect(result).toEqual({ + subtotal: 175, + taxAmount: 35, + totalAmount: 210, + }); + }); + + it('calculates totals correctly with both main and usage lines', () => { + const mainLines = [createMainLine(1000)]; + const usageLines = [createUsageLine(100, 50, 2)]; + const result = calculateEstimateTotals( + 'estimate', + 0, + 0, + 0, + 10, + mainLines, + usageLines, + ); + + expect(result).toEqual({ + subtotal: 1100, + taxAmount: 110, + totalAmount: 1210, + }); + }); + + it('handles usage lines with no overage', () => { + const usageLines = [ + createUsageLine(30, 50, 2), + createUsageLine(100, 100, 1.5), + ]; + const result = calculateEstimateTotals( + 'estimate', + 0, + 0, + 0, + 15, + [], + usageLines, + ); + + expect(result).toEqual({ + subtotal: 0, + taxAmount: 0, + totalAmount: 0, + }); + }); + + it('handles usage lines with missing consumption or limit', () => { + const usageLines = [ + createUsageLine(100, 50, 2), + { ...createUsageLine(0, 0, 0), consumption: undefined }, + { ...createUsageLine(0, 0, 0), limit: undefined }, + ]; + const result = calculateEstimateTotals( + 'estimate', + 0, + 0, + 0, + 10, + [], + usageLines, + ); + + expect(result).toEqual({ + subtotal: 100, + taxAmount: 10, + totalAmount: 110, + }); + }); + + it('handles zero tax percentage', () => { + const mainLines = [createMainLine(1000)]; + const result = calculateEstimateTotals( + 'estimate', + 0, + 0, + 0, + 0, + mainLines, + [], + ); + + expect(result).toEqual({ + subtotal: 1000, + taxAmount: 0, + totalAmount: 1000, + }); + }); + + it('handles undefined tax percentage', () => { + const mainLines = [createMainLine(1000)]; + const result = calculateEstimateTotals( + 'estimate', + 0, + 0, + 0, + undefined, + mainLines, + [], + ); + + expect(result).toEqual({ + subtotal: 1000, + taxAmount: 0, + totalAmount: 1000, + }); + }); + + it('handles empty arrays', () => { + const result = calculateEstimateTotals( + 'estimate', + 0, + 0, + 0, + 15, + [], + [], + ); + + expect(result).toEqual({ + subtotal: 0, + taxAmount: 0, + totalAmount: 0, + }); + }); + + it('handles main lines with zero totalAmount', () => { + const mainLines = [ + createMainLine(1000), + createMainLine(0), + createMainLine(500), + ]; + const result = calculateEstimateTotals( + 'estimate', + 0, + 0, + 0, + 10, + mainLines, + [], + ); + + expect(result).toEqual({ + subtotal: 1500, + taxAmount: 150, + totalAmount: 1650, + }); + }); + + it('handles usage lines with zero unitPrice', () => { + const usageLines = [ + createUsageLine(100, 50, 2), + createUsageLine(200, 100, 0), + ]; + const result = calculateEstimateTotals( + 'estimate', + 0, + 0, + 0, + 10, + [], + usageLines, + ); + + expect(result).toEqual({ + subtotal: 100, + taxAmount: 10, + totalAmount: 110, + }); + }); + }); +}); diff --git a/frontend/src/component/admin/billing/BillingInvoices/BillingInvoice/calculateEstimateTotals.ts b/frontend/src/component/admin/billing/BillingInvoices/BillingInvoice/calculateEstimateTotals.ts new file mode 100644 index 0000000000..c0b131560c --- /dev/null +++ b/frontend/src/component/admin/billing/BillingInvoices/BillingInvoice/calculateEstimateTotals.ts @@ -0,0 +1,44 @@ +import type { DetailedInvoicesLineSchema } from 'openapi'; + +export const calculateEstimateTotals = ( + status: string, + subtotal: number, + taxAmount: number, + totalAmount: number, + taxPercentage: number | undefined, + mainLines: DetailedInvoicesLineSchema[], + usageLines: DetailedInvoicesLineSchema[], +) => { + if (status !== 'estimate') { + return { + subtotal: subtotal, + taxAmount: taxAmount, + totalAmount: totalAmount, + }; + } + + const mainLinesTotal = mainLines.reduce( + (sum, line) => sum + (line.totalAmount || 0), + 0, + ); + + const usageLinesTotal = usageLines.reduce((sum, line) => { + const overage = + line.consumption && line.limit + ? Math.max(0, line.consumption - line.limit) + : 0; + return sum + overage * (line.unitPrice || 0); + }, 0); + + const calculatedSubtotal = mainLinesTotal + usageLinesTotal; + const calculatedTaxAmount = taxPercentage + ? calculatedSubtotal * (taxPercentage / 100) + : 0; + const calculatedTotalAmount = calculatedSubtotal + calculatedTaxAmount; + + return { + subtotal: calculatedSubtotal, + taxAmount: calculatedTaxAmount, + totalAmount: calculatedTotalAmount, + }; +};