Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions client/src/components/budget/BudgetLineCard.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -54,12 +54,12 @@ export function BudgetLineCard({
{isQuotation ? t('vendorDetail.quotedAmount') : 'Invoiced Amount'}
</span>
<span className={styles.plannedSecondary}>
(planned: {formatCurrency(line.plannedAmount)})
(planned: {formatCurrency(effectivePlannedAmount(line))})
</span>
</>
) : (
<>
<span className={styles.amount}>{formatCurrency(line.plannedAmount)}</span>
<span className={styles.amount}>{formatCurrency(effectivePlannedAmount(line))}</span>
<span className={styles.confidence}>
{confidenceLabels[line.confidence]}
{CONFIDENCE_MARGINS[line.confidence] > 0 && (
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/budget/BudgetSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -133,7 +133,7 @@ export function BudgetSection<T extends BaseBudgetLine>({
(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 (
<InvoiceGroup
Expand Down
4 changes: 1 addition & 3 deletions client/src/hooks/useBudgetSection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,7 @@ export function useBudgetSection<T extends BaseBudgetLine>(
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);
Expand Down
12 changes: 6 additions & 6 deletions client/src/lib/budgetConstants.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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) => {
Expand All @@ -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) => {
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 3 additions & 6 deletions client/src/pages/WorkItemDetailPage/WorkItemDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,22 +264,19 @@ 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,
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,
Expand Down
9 changes: 9 additions & 0 deletions server/src/db/migrations/0031_fix_vat_storage_semantics.sql
Original file line number Diff line number Diff line change
@@ -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;
27 changes: 19 additions & 8 deletions server/src/services/budgetOverviewService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,21 +51,24 @@ 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
id AS id,
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`,
);

Expand Down Expand Up @@ -197,10 +200,17 @@ export function getBudgetOverview(db: DbType): BudgetOverview {
const fixedSubsidyLineCountCache = new Map<string, number>();
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;
Expand All @@ -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') {
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
}));

Expand Down
1 change: 1 addition & 0 deletions shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export type {
BudgetSummary,
BudgetLineInvoiceLink,
} from './types/budget.js';
export { effectivePlannedAmount } from './types/budget.js';

// Subsidy Programs
export type {
Expand Down
14 changes: 14 additions & 0 deletions shared/src/types/budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down