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.