diff --git a/client/src/components/budget/BudgetLineCard.tsx b/client/src/components/budget/BudgetLineCard.tsx index ee8659300..9a4e2dc03 100644 --- a/client/src/components/budget/BudgetLineCard.tsx +++ b/client/src/components/budget/BudgetLineCard.tsx @@ -1,7 +1,7 @@ import { type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import type { BaseBudgetLine, ConfidenceLevel } from '@cornerstone/shared'; -import { CONFIDENCE_MARGINS } from '../../lib/budgetConstants.js'; +import { CONFIDENCE_MARGINS, effectivePlannedAmount } from '../../lib/budgetConstants.js'; import { useFormatters } from '../../lib/formatters.js'; import { getCategoryDisplayName } from '../../lib/categoryUtils.js'; import styles from './BudgetLineCard.module.css'; @@ -54,12 +54,12 @@ export function BudgetLineCard({ {isQuotation ? t('vendorDetail.quotedAmount') : 'Invoiced Amount'} - (planned: {formatCurrency(line.plannedAmount)}) + (planned: {formatCurrency(effectivePlannedAmount(line))}) ) : ( <> - {formatCurrency(line.plannedAmount)} + {formatCurrency(effectivePlannedAmount(line))} {confidenceLabels[line.confidence]} {CONFIDENCE_MARGINS[line.confidence] > 0 && ( diff --git a/client/src/components/budget/BudgetSection.tsx b/client/src/components/budget/BudgetSection.tsx index 011b559a4..328df141e 100644 --- a/client/src/components/budget/BudgetSection.tsx +++ b/client/src/components/budget/BudgetSection.tsx @@ -7,7 +7,7 @@ import type { SubsidyProgram, } from '@cornerstone/shared'; import type { UseBudgetSectionReturn } from '../../hooks/useBudgetSection.js'; -import { CONFIDENCE_LABELS } from '../../lib/budgetConstants.js'; +import { CONFIDENCE_LABELS, effectivePlannedAmount } from '../../lib/budgetConstants.js'; import { BudgetLineCard } from './BudgetLineCard.js'; import { BudgetLineForm } from './BudgetLineForm.js'; import { SubsidyLinkSection } from './SubsidyLinkSection.js'; @@ -133,7 +133,7 @@ export function BudgetSection({ (sum, line) => sum + (line.invoiceLink?.itemizedAmount || 0), 0, ); - const plannedTotal = groupLines.reduce((sum, line) => sum + line.plannedAmount, 0); + const plannedTotal = groupLines.reduce((sum, line) => sum + effectivePlannedAmount(line), 0); return ( ( return; } - // Calculate planned amount: qty * price * (includesVat ? 1 : 1.19) - const multiplier = budgetForm.includesVat ? 1 : 1.19; - plannedAmount = Math.round(qty * price * multiplier * 100) / 100; + plannedAmount = Math.round(qty * price * 100) / 100; } setIsSavingBudget(true); diff --git a/client/src/lib/budgetConstants.ts b/client/src/lib/budgetConstants.ts index f5f4e8dd8..c811d239f 100644 --- a/client/src/lib/budgetConstants.ts +++ b/client/src/lib/budgetConstants.ts @@ -1,8 +1,8 @@ import type { ConfidenceLevel, BaseBudgetLine } from '@cornerstone/shared'; -import { CONFIDENCE_MARGINS } from '@cornerstone/shared'; +import { CONFIDENCE_MARGINS, effectivePlannedAmount } from '@cornerstone/shared'; -// Re-export CONFIDENCE_MARGINS for convenience in client code -export { CONFIDENCE_MARGINS }; +// Re-export for convenience in client code +export { CONFIDENCE_MARGINS, effectivePlannedAmount }; /** * Human-readable labels for confidence levels used in budget forms and displays. @@ -36,7 +36,7 @@ export interface BudgetTotals { * @returns BudgetTotals object with planned, actual, and range values */ export function computeBudgetTotals(budgetLines: BaseBudgetLine[]): BudgetTotals { - const totalPlanned = budgetLines.reduce((sum, b) => sum + b.plannedAmount, 0); + const totalPlanned = budgetLines.reduce((sum, b) => sum + effectivePlannedAmount(b), 0); const totalActualCost = budgetLines.reduce((sum, b) => sum + b.actualCost, 0); const totalMinPlanned = budgetLines.reduce((sum, b) => { @@ -45,7 +45,7 @@ export function computeBudgetTotals(budgetLines: BaseBudgetLine[]): BudgetTotals return sum + b.actualCost; } const margin = CONFIDENCE_MARGINS[b.confidence] ?? 0; - return sum + b.plannedAmount * (1 - margin); + return sum + effectivePlannedAmount(b) * (1 - margin); }, 0); const totalMaxPlanned = budgetLines.reduce((sum, b) => { @@ -54,7 +54,7 @@ export function computeBudgetTotals(budgetLines: BaseBudgetLine[]): BudgetTotals return sum + b.actualCost; } const margin = CONFIDENCE_MARGINS[b.confidence] ?? 0; - return sum + b.plannedAmount * (1 + margin); + return sum + effectivePlannedAmount(b) * (1 + margin); }, 0); const hasPlannedRange = Math.abs(totalMaxPlanned - totalMinPlanned) > 0.01; diff --git a/client/src/pages/HouseholdItemDetailPage/HouseholdItemDetailPage.tsx b/client/src/pages/HouseholdItemDetailPage/HouseholdItemDetailPage.tsx index e9990e64a..0eef24fb7 100644 --- a/client/src/pages/HouseholdItemDetailPage/HouseholdItemDetailPage.tsx +++ b/client/src/pages/HouseholdItemDetailPage/HouseholdItemDetailPage.tsx @@ -211,21 +211,18 @@ export function HouseholdItemDetailPage() { quantity: line.quantity !== null ? String(line.quantity) : '', unit: line.unit ?? '', unitPrice: line.unitPrice !== null ? String(line.unitPrice) : '', - includesVat: line.quantity !== null ? (line.includesVat ?? true) : false, + includesVat: line.includesVat ?? true, }), toPayload: (form: BudgetLineFormState) => ({ description: form.description.trim() || null, - plannedAmount: - form.pricingMode === 'direct' && form.includesVat - ? Math.round((parseFloat(form.plannedAmount) / 1.19) * 100) / 100 - : parseFloat(form.plannedAmount), + plannedAmount: parseFloat(form.plannedAmount), confidence: form.confidence, budgetSourceId: form.budgetSourceId, vendorId: form.vendorId || null, quantity: form.pricingMode === 'unit' && form.quantity ? parseFloat(form.quantity) : null, unit: form.pricingMode === 'unit' && form.unit ? form.unit : null, unitPrice: form.pricingMode === 'unit' && form.unitPrice ? parseFloat(form.unitPrice) : null, - includesVat: form.pricingMode === 'unit' ? form.includesVat : null, + includesVat: form.includesVat, }), entityId: id ?? '', defaultBudgetSourceId: budgetSources.find((s) => s.isDiscretionary)?.id, diff --git a/client/src/pages/WorkItemDetailPage/WorkItemDetailPage.tsx b/client/src/pages/WorkItemDetailPage/WorkItemDetailPage.tsx index e64d76edc..c5b9f9902 100644 --- a/client/src/pages/WorkItemDetailPage/WorkItemDetailPage.tsx +++ b/client/src/pages/WorkItemDetailPage/WorkItemDetailPage.tsx @@ -264,14 +264,11 @@ export default function WorkItemDetailPage() { quantity: line.quantity !== null ? String(line.quantity) : '', unit: line.unit ?? '', unitPrice: line.unitPrice !== null ? String(line.unitPrice) : '', - includesVat: line.quantity !== null ? (line.includesVat ?? true) : false, + includesVat: line.includesVat ?? true, }), toPayload: (form: BudgetLineFormState): CreateWorkItemBudgetRequest => ({ description: form.description.trim() || null, - plannedAmount: - form.pricingMode === 'direct' && form.includesVat - ? Math.round((parseFloat(form.plannedAmount) / 1.19) * 100) / 100 - : parseFloat(form.plannedAmount), + plannedAmount: parseFloat(form.plannedAmount), confidence: form.confidence, budgetCategoryId: form.budgetCategoryId || null, budgetSourceId: form.budgetSourceId, @@ -279,7 +276,7 @@ export default function WorkItemDetailPage() { quantity: form.pricingMode === 'unit' && form.quantity ? parseFloat(form.quantity) : null, unit: form.pricingMode === 'unit' && form.unit ? form.unit : null, unitPrice: form.pricingMode === 'unit' && form.unitPrice ? parseFloat(form.unitPrice) : null, - includesVat: form.pricingMode === 'unit' ? form.includesVat : null, + includesVat: form.includesVat, }), entityId: id ?? '', defaultBudgetSourceId: budgetSources.find((s) => s.isDiscretionary)?.id, diff --git a/server/src/db/migrations/0031_fix_vat_storage_semantics.sql b/server/src/db/migrations/0031_fix_vat_storage_semantics.sql new file mode 100644 index 000000000..4aa224a90 --- /dev/null +++ b/server/src/db/migrations/0031_fix_vat_storage_semantics.sql @@ -0,0 +1,9 @@ +-- Convert existing unit-pricing budget lines (includes_vat = false) from +-- VAT-inclusive storage back to net amounts. Display now applies × 1.19. +UPDATE work_item_budgets +SET planned_amount = ROUND(planned_amount / 1.19, 2) +WHERE quantity IS NOT NULL AND includes_vat = 0; + +UPDATE household_item_budgets +SET planned_amount = ROUND(planned_amount / 1.19, 2) +WHERE quantity IS NOT NULL AND includes_vat = 0; diff --git a/server/src/services/budgetOverviewService.ts b/server/src/services/budgetOverviewService.ts index 1fb30fd7a..04b4079c8 100644 --- a/server/src/services/budgetOverviewService.ts +++ b/server/src/services/budgetOverviewService.ts @@ -51,13 +51,15 @@ export function getBudgetOverview(db: DbType): BudgetOverview { plannedAmount: number; confidence: string; budgetCategoryId: string | null; + includesVat: number | null; }>( sql`SELECT id AS id, work_item_id AS entityId, planned_amount AS plannedAmount, confidence AS confidence, - budget_category_id AS budgetCategoryId + budget_category_id AS budgetCategoryId, + includes_vat AS includesVat FROM work_item_budgets UNION ALL SELECT @@ -65,7 +67,8 @@ export function getBudgetOverview(db: DbType): BudgetOverview { household_item_id AS entityId, planned_amount AS plannedAmount, confidence AS confidence, - budget_category_id AS budgetCategoryId + budget_category_id AS budgetCategoryId, + includes_vat AS includesVat FROM household_item_budgets`, ); @@ -197,10 +200,17 @@ export function getBudgetOverview(db: DbType): BudgetOverview { const fixedSubsidyLineCountCache = new Map(); let totalReductions = 0; + // VAT helper: convert stored net amount to effective amount if VAT not included + // SQLite returns 0/1 for boolean, so includesVat === 0 means false (VAT should be applied) + const effective = (l: { plannedAmount: number; includesVat: number | null }): number => + l.includesVat === 0 + ? Math.round(l.plannedAmount * 1.19 * 100) / 100 + : l.plannedAmount; + for (const line of budgetLines) { const margin = CONFIDENCE_MARGINS[line.confidence as keyof typeof CONFIDENCE_MARGINS] ?? 0; - const rawMin = line.plannedAmount * (1 - margin); - const rawMax = line.plannedAmount * (1 + margin); + const rawMin = effective(line) * (1 - margin); + const rawMax = effective(line) * (1 + margin); // Compute subsidy reduction for this line let subsidyReduction = 0; @@ -218,10 +228,10 @@ export function getBudgetOverview(db: DbType): BudgetOverview { continue; } - // Determine cost basis: use invoice amount if available, otherwise planned amount + // Determine cost basis: use invoice amount if available, otherwise effective planned amount const costBasis = lineInvoiceMap.has(line.id) ? lineInvoiceMap.get(line.id)!.actualCost - : line.plannedAmount; + : effective(line); // This subsidy applies to this line if (meta.reductionType === 'percentage') { @@ -330,6 +340,7 @@ export function getBudgetOverview(db: DbType): BudgetOverview { plannedAmount: number; confidence: string; budgetCategoryId: string | null; + includesVat: number | null; }[] >(); for (const line of budgetLines) { @@ -345,11 +356,11 @@ export function getBudgetOverview(db: DbType): BudgetOverview { for (const [entityId, linkedSubsidyIds] of entitySubsidyMap) { const entityLines = linesByEntity.get(entityId) ?? []; - // Build engine inputs from the entity's budget lines + // Build engine inputs from the entity's budget lines (using effective amounts) const engineLines = entityLines.map((line) => ({ id: line.id, budgetCategoryId: line.budgetCategoryId, - plannedAmount: line.plannedAmount, + plannedAmount: effective(line), confidence: line.confidence, })); diff --git a/shared/src/index.ts b/shared/src/index.ts index 92d1acff6..0febcbc7c 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -170,6 +170,7 @@ export type { BudgetSummary, BudgetLineInvoiceLink, } from './types/budget.js'; +export { effectivePlannedAmount } from './types/budget.js'; // Subsidy Programs export type { diff --git a/shared/src/types/budget.ts b/shared/src/types/budget.ts index 358ca2aa6..457ea2912 100644 --- a/shared/src/types/budget.ts +++ b/shared/src/types/budget.ts @@ -92,6 +92,20 @@ export interface BaseBudgetLine { updatedAt: string; } +/** + * Returns the effective planned amount for display and aggregation. + * When includesVat is explicitly false, the stored amount is net; multiply by 1.19. + * null is treated as true (use as-is). + */ +export function effectivePlannedAmount(line: { + plannedAmount: number; + includesVat: boolean | null; +}): number { + return line.includesVat === false + ? Math.round(line.plannedAmount * 1.19 * 100) / 100 + : line.plannedAmount; +} + /** * Request body for creating a new budget line. * Used for both work item and household item budgets.