diff --git a/app/src/adapters/HouseholdAdapter.ts b/app/src/adapters/HouseholdAdapter.ts deleted file mode 100644 index 0a560be47..000000000 --- a/app/src/adapters/HouseholdAdapter.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { countryIds } from '@/libs/countries'; -import { store } from '@/store'; -import { Household, HouseholdData } from '@/types/ingredients/Household'; -import { HouseholdMetadata } from '@/types/metadata/householdMetadata'; -import { HouseholdCreationPayload } from '@/types/payloads'; - -/** - * Get entity metadata from the Redux store - */ -function getEntityMetadata() { - const state = store.getState(); - return state.metadata?.entities || {}; -} - -/** - * Convert entity name from camelCase to snake_case plural form using metadata - */ -function getEntityPluralKey(entityName: string): string | null { - const entities = getEntityMetadata(); - - // Special case for 'people' which is already plural - if (entityName === 'people') { - return 'people'; - } - - // Look for an entity whose plural matches the entityName - for (const [_key, entity] of Object.entries(entities)) { - if ((entity as any).plural === entityName) { - return entityName; - } - } - - // Try to find by converting camelCase to snake_case - // e.g., 'taxUnits' -> 'tax_units', 'maritalUnits' -> 'marital_units' - const snakeCase = entityName.replace(/([A-Z])/g, '_$1').toLowerCase(); - for (const [_key, entity] of Object.entries(entities)) { - if ((entity as any).plural === snakeCase) { - return snakeCase; - } - } - - return null; -} - -/** - * Validate that an entity name exists in metadata - */ -function validateEntityName(entityName: string): void { - if (entityName === 'people') { - return; // People is always valid - } - - const pluralKey = getEntityPluralKey(entityName); - if (!pluralKey) { - const entities = getEntityMetadata(); - const validEntities = Object.values(entities) - .map((e: any) => e.plural) - .join(', '); - throw new Error(`Unknown entity "${entityName}". Valid entities are: people, ${validEntities}`); - } -} - -/** - * Adapter to convert between API format (HouseholdMetadata) and internal format (Household) - */ -export class HouseholdAdapter { - /** - * Convert API response to internal Household format - * Dynamically handles all entity types based on metadata - */ - static fromMetadata(metadata: HouseholdMetadata): Household { - const householdData: HouseholdData = { - people: metadata.household_json.people as any, - }; - - // Iterate over all keys in household_json - for (const [key, value] of Object.entries(metadata.household_json)) { - if (key === 'people') { - continue; // Already handled - } - - // Try to validate the entity exists in metadata - try { - validateEntityName(key); - // Convert snake_case to camelCase for internal representation - const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); - householdData[camelKey] = value as any; - } catch { - // If entity not found in metadata, still include it but log warning - console.warn(`Entity "${key}" not found in metadata, including anyway`); - const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); - householdData[camelKey] = value as any; - } - } - - return { - id: metadata.id, - countryId: metadata.country_id as (typeof countryIds)[number], - householdData, - }; - } - - /** - * Create a minimal Household for creation requests - * Dynamically handles all entity types based on metadata - */ - static toCreationPayload( - householdData: HouseholdData, - countryId: string - ): HouseholdCreationPayload { - const household_json: any = { - people: householdData.people as any, - }; - - // Iterate over all keys in householdData - for (const [key, value] of Object.entries(householdData)) { - if (key === 'people') { - continue; // Already handled - } - - // Get the plural form from metadata - const pluralKey = getEntityPluralKey(key); - if (pluralKey) { - household_json[pluralKey] = value as any; - } else { - // If not found in metadata, try snake_case conversion - const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - console.warn(`Entity "${key}" not found in metadata, using snake_case "${snakeKey}"`); - household_json[snakeKey] = value as any; - } - } - - return { - country_id: countryId, - data: household_json, - }; - } -} diff --git a/app/src/adapters/index.ts b/app/src/adapters/index.ts index e6d8da67e..55e1dda5a 100644 --- a/app/src/adapters/index.ts +++ b/app/src/adapters/index.ts @@ -2,7 +2,6 @@ export { PolicyAdapter } from './PolicyAdapter'; export { SimulationAdapter } from './SimulationAdapter'; export { ReportAdapter } from './ReportAdapter'; -export { HouseholdAdapter } from './HouseholdAdapter'; // User Ingredient Adapters export { UserReportAdapter } from './UserReportAdapter'; diff --git a/app/src/api/household.ts b/app/src/api/household.ts index 803823166..e90c406ab 100644 --- a/app/src/api/household.ts +++ b/app/src/api/household.ts @@ -1,11 +1,11 @@ import { BASE_URL } from '@/constants'; -import { HouseholdMetadata } from '@/types/metadata/householdMetadata'; +import type { V1HouseholdMetadataEnvelope } from '@/models/household/v1Types'; import { HouseholdCreationPayload } from '@/types/payloads'; export async function fetchHouseholdById( country: string, household: string -): Promise { +): Promise { const url = `${BASE_URL}/${country}/household/${household}`; const res = await fetch(url, { diff --git a/app/src/api/householdCalculation.ts b/app/src/api/householdCalculation.ts index 1a4b06dac..3913db53b 100644 --- a/app/src/api/householdCalculation.ts +++ b/app/src/api/householdCalculation.ts @@ -1,6 +1,6 @@ import type { PolicyEngineBundle } from '@/api/societyWideCalculation'; import { BASE_URL } from '@/constants'; -import { HouseholdData } from '@/types/ingredients/Household'; +import type { AppHouseholdInputData as HouseholdData } from '@/models/household/appTypes'; export interface HouseholdCalculationResponse { status: 'ok' | 'error'; diff --git a/app/src/api/v2/householdCalculation.ts b/app/src/api/v2/householdCalculation.ts index 1f6b56db8..68da5be2f 100644 --- a/app/src/api/v2/householdCalculation.ts +++ b/app/src/api/v2/householdCalculation.ts @@ -7,64 +7,55 @@ * Note: Variation/axes calculations are NOT supported in v2 alpha and remain on v1. */ -import type { CountryId } from '@/libs/countries'; +import type { + V2CreateHouseholdEnvelope, + V2HouseholdCalculationPayload, + V2HouseholdCalculationResult, + V2HouseholdEnvelope, + V2StoredHouseholdEnvelope, + V2UKCreateHouseholdEnvelope, + V2UKHouseholdCalculationResult, + V2USCreateHouseholdEnvelope, + V2USHouseholdCalculationResult, +} from '@/models/household/v2Types'; import { API_V2_BASE_URL } from './taxBenefitModels'; import { cancellableSleep, v2Fetch } from './v2Fetch'; -/** - * V2-specific flat household shape used by calculation endpoints. - * This is the v2 API's native format — conversion from the app's - * internal Household type happens in the adapter layer (Phase 2). - */ -export interface V2HouseholdShape { - id?: string; - country_id: CountryId; - year: number; - label?: string | null; - people: Record[]; - tax_unit?: Record | null; - family?: Record | null; - spm_unit?: Record | null; - marital_unit?: Record | null; - household?: Record | null; - benunit?: Record | null; -} +export type { V2CreateHouseholdEnvelope, V2StoredHouseholdEnvelope }; -/** - * Payload sent to POST /household/calculate - */ -export interface HouseholdCalculatePayload { - country_id: CountryId; - year: number; - people: Record[]; - tax_unit?: Record | null; - family?: Record | null; - spm_unit?: Record | null; - marital_unit?: Record | null; - household?: Record | null; - benunit?: Record | null; - policy_id?: string; - dynamic_id?: string; -} +export type HouseholdCalculatePayload = V2HouseholdCalculationPayload; +export type HouseholdCalculationResult = V2HouseholdCalculationResult; function householdToCalculatePayload( - household: V2HouseholdShape, + household: V2HouseholdEnvelope, policyId?: string, dynamicId?: string ): HouseholdCalculatePayload { - return { - country_id: household.country_id, - year: household.year, - people: household.people, - tax_unit: household.tax_unit ?? null, - family: household.family ?? null, - spm_unit: household.spm_unit ?? null, - marital_unit: household.marital_unit ?? null, - household: household.household ?? null, - benunit: household.benunit ?? null, - policy_id: policyId, - dynamic_id: dynamicId, - }; + switch (household.country_id) { + case 'us': + return { + country_id: 'us', + year: household.year, + people: household.people, + tax_unit: household.tax_unit, + family: household.family, + spm_unit: household.spm_unit, + marital_unit: household.marital_unit, + household: household.household, + policy_id: policyId, + dynamic_id: dynamicId, + }; + case 'uk': + return { + country_id: 'uk', + year: household.year, + people: household.people, + household: household.household, + benunit: household.benunit, + policy_id: policyId, + dynamic_id: dynamicId, + }; + } } // ============================================================================ @@ -91,19 +82,6 @@ export interface HouseholdJobStatusResponse { error_message: string | null; } -/** - * Calculation result structure from v2 alpha - */ -export interface HouseholdCalculationResult { - person: Record[]; - benunit?: Record[] | null; - marital_unit?: Record[] | null; - family?: Record[] | null; - spm_unit?: Record[] | null; - tax_unit?: Record[] | null; - household: Record[]; -} - // ============================================================================ // API Functions // ============================================================================ @@ -205,20 +183,31 @@ export async function pollHouseholdCalculationJobV2( */ export function calculationResultToHousehold( result: HouseholdCalculationResult, - originalHousehold: V2HouseholdShape -): V2HouseholdShape { - return { - country_id: originalHousehold.country_id, - year: originalHousehold.year, - people: result.person, - // Extract first element from arrays (single household case) - tax_unit: result.tax_unit?.[0] ?? undefined, - family: result.family?.[0] ?? undefined, - spm_unit: result.spm_unit?.[0] ?? undefined, - marital_unit: result.marital_unit?.[0] ?? undefined, - household: result.household?.[0] ?? undefined, - benunit: result.benunit?.[0] ?? undefined, - }; + originalHousehold: V2HouseholdEnvelope +): V2CreateHouseholdEnvelope { + switch (originalHousehold.country_id) { + case 'us': + return { + country_id: 'us', + year: originalHousehold.year, + label: originalHousehold.label, + people: (result as V2USHouseholdCalculationResult).person, + tax_unit: (result as V2USHouseholdCalculationResult).tax_unit ?? [], + family: (result as V2USHouseholdCalculationResult).family ?? [], + spm_unit: (result as V2USHouseholdCalculationResult).spm_unit ?? [], + marital_unit: (result as V2USHouseholdCalculationResult).marital_unit ?? [], + household: (result as V2USHouseholdCalculationResult).household ?? [], + } satisfies V2USCreateHouseholdEnvelope; + case 'uk': + return { + country_id: 'uk', + year: originalHousehold.year, + label: originalHousehold.label, + people: (result as V2UKHouseholdCalculationResult).person, + household: (result as V2UKHouseholdCalculationResult).household ?? [], + benunit: (result as V2UKHouseholdCalculationResult).benunit ?? [], + } satisfies V2UKCreateHouseholdEnvelope; + } } /** @@ -226,7 +215,7 @@ export function calculationResultToHousehold( * Creates job, polls for result, returns Household */ export async function calculateHouseholdV2Alpha( - household: V2HouseholdShape, + household: V2HouseholdEnvelope, policyId?: string, dynamicId?: string, options: { @@ -234,7 +223,7 @@ export async function calculateHouseholdV2Alpha( timeoutMs?: number; signal?: AbortSignal; } = {} -): Promise { +): Promise { // Convert to calculation payload format (arrays) const payload = householdToCalculatePayload(household, policyId, dynamicId); diff --git a/app/src/api/v2/households.ts b/app/src/api/v2/households.ts index 9a3ed6b8a..02fcaf589 100644 --- a/app/src/api/v2/households.ts +++ b/app/src/api/v2/households.ts @@ -8,126 +8,46 @@ */ import type { CountryId } from '@/libs/countries'; -import type { V2HouseholdShape } from './householdCalculation'; +import type { + V2CreateHouseholdEnvelope, + V2StoredHouseholdEnvelope, +} from '@/models/household/v2Types'; import { API_V2_BASE_URL } from './taxBenefitModels'; import { v2Fetch, v2FetchVoid } from './v2Fetch'; -// ============================================================================ -// Types for v2 Alpha /households API -// ============================================================================ - -/** - * Response from v2 alpha household endpoints (HouseholdRead schema) - */ -export interface HouseholdV2Response { - id: string; // UUID - country_id: CountryId; - year: number; - label: string | null; - people: Record[]; - tax_unit?: Record | null; - family?: Record | null; - spm_unit?: Record | null; - marital_unit?: Record | null; - household?: Record | null; - benunit?: Record | null; - created_at: string; - updated_at: string; -} - -/** - * Request body for creating a household (HouseholdCreate schema) - */ -export interface HouseholdV2CreateRequest { - country_id: CountryId; - year: number; - label?: string | null; - people: Record[]; - tax_unit?: Record | null; - family?: Record | null; - spm_unit?: Record | null; - marital_unit?: Record | null; - household?: Record | null; - benunit?: Record | null; -} - -// ============================================================================ -// Conversion Functions -// ============================================================================ - -/** - * Convert app Household to v2 alpha API request format - */ -export function householdToV2Request(household: V2HouseholdShape): HouseholdV2CreateRequest { - return { - country_id: household.country_id, - year: household.year, - label: household.label ?? null, - people: household.people, - tax_unit: household.tax_unit ?? null, - family: household.family ?? null, - spm_unit: household.spm_unit ?? null, - marital_unit: household.marital_unit ?? null, - household: household.household ?? null, - benunit: household.benunit ?? null, - }; -} - -/** - * Convert v2 alpha API response to app Household format - */ -export function v2ResponseToHousehold(response: HouseholdV2Response): V2HouseholdShape { - return { - id: response.id, - country_id: response.country_id, - year: response.year, - label: response.label ?? undefined, - people: response.people, - tax_unit: response.tax_unit ?? undefined, - family: response.family ?? undefined, - spm_unit: response.spm_unit ?? undefined, - marital_unit: response.marital_unit ?? undefined, - household: response.household ?? undefined, - benunit: response.benunit ?? undefined, - }; -} - -// ============================================================================ -// API Functions -// ============================================================================ - /** * Create a new household in v2 alpha API */ -export async function createHouseholdV2(household: V2HouseholdShape): Promise { +export async function createHouseholdV2( + household: V2CreateHouseholdEnvelope +): Promise { const url = `${API_V2_BASE_URL}/households/`; - const body = householdToV2Request(household); - const json = await v2Fetch(url, 'createHouseholdV2', { + return v2Fetch(url, 'createHouseholdV2', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, - body: JSON.stringify(body), + body: JSON.stringify(household), }); - return v2ResponseToHousehold(json); } /** * Fetch a household by ID from v2 alpha API. * Throws with status code in message on any error (including 404). */ -export async function fetchHouseholdByIdV2(householdId: string): Promise { +export async function fetchHouseholdByIdV2( + householdId: string +): Promise { const url = `${API_V2_BASE_URL}/households/${householdId}`; - const json = await v2Fetch(url, `fetchHouseholdByIdV2(${householdId})`, { + return v2Fetch(url, `fetchHouseholdByIdV2(${householdId})`, { method: 'GET', headers: { Accept: 'application/json', }, }); - return v2ResponseToHousehold(json); } /** @@ -137,7 +57,7 @@ export async function listHouseholdsV2(options?: { country_id?: CountryId; limit?: number; offset?: number; -}): Promise { +}): Promise { const params = new URLSearchParams(); if (options?.country_id) { @@ -153,13 +73,12 @@ export async function listHouseholdsV2(options?: { const queryString = params.toString(); const url = `${API_V2_BASE_URL}/households/${queryString ? `?${queryString}` : ''}`; - const json = await v2Fetch(url, 'listHouseholdsV2', { + return v2Fetch(url, 'listHouseholdsV2', { method: 'GET', headers: { Accept: 'application/json', }, }); - return json.map(v2ResponseToHousehold); } /** diff --git a/app/src/api/v2/index.ts b/app/src/api/v2/index.ts index 9878fd0bb..e04d5327a 100644 --- a/app/src/api/v2/index.ts +++ b/app/src/api/v2/index.ts @@ -52,10 +52,6 @@ export { fetchHouseholdByIdV2, listHouseholdsV2, deleteHouseholdV2, - householdToV2Request, - v2ResponseToHousehold, - type HouseholdV2Response, - type HouseholdV2CreateRequest, } from './households'; // Household Calculation (v2 Alpha async jobs) @@ -80,7 +76,7 @@ export { deleteUserHouseholdAssociationV2, type UserHouseholdAssociationV2Response, type UserHouseholdAssociationV2CreateRequest, - type UserHouseholdAssociationV2UpdateRequest, + type UserHouseholdAssociationV2UpdateInput, } from './userHouseholdAssociations'; // Simulations (v2 Alpha — household + economy) diff --git a/app/src/api/v2/reportFull.ts b/app/src/api/v2/reportFull.ts index ee6301e8e..f07d15e08 100644 --- a/app/src/api/v2/reportFull.ts +++ b/app/src/api/v2/reportFull.ts @@ -19,6 +19,7 @@ import { SimulationInfo, } from '@/api/v2/economyAnalysis'; import { HouseholdImpactResponse } from '@/api/v2/householdAnalysis'; +import type { V2StoredHouseholdEnvelope } from '@/models/household/v2Types'; import { API_V2_BASE_URL } from './taxBenefitModels'; import { v2Fetch } from './v2Fetch'; @@ -38,29 +39,13 @@ export interface ReportReadResponse { created_at: string; } -export interface HouseholdReadResponse { - id: string; - country_id: string; - year: number; - label: string | null; - people: Record[]; - tax_unit?: Record | null; - family?: Record | null; - spm_unit?: Record | null; - marital_unit?: Record | null; - household?: Record | null; - benunit?: Record | null; - created_at: string; - updated_at: string; -} - export interface ReportFullResponse { report: ReportReadResponse; baseline_simulation: SimulationInfo | null; reform_simulation: SimulationInfo | null; baseline_policy: V2PolicyResponse | null; reform_policy: V2PolicyResponse | null; - household: HouseholdReadResponse | null; + household: V2StoredHouseholdEnvelope | null; region: AnalysisRegionInfo | null; economic_impact: EconomicImpactResponse | null; household_impact: HouseholdImpactResponse | null; diff --git a/app/src/api/v2/userHouseholdAssociations.ts b/app/src/api/v2/userHouseholdAssociations.ts index 392cb9e63..d8b7c1a66 100644 --- a/app/src/api/v2/userHouseholdAssociations.ts +++ b/app/src/api/v2/userHouseholdAssociations.ts @@ -44,10 +44,12 @@ export interface UserHouseholdAssociationV2CreateRequest { } /** - * API request format for updating associations - matches UserHouseholdAssociationUpdate + * App-facing update input for household associations. + * This stays camelCase; serialization to the wire format happens below. */ -export interface UserHouseholdAssociationV2UpdateRequest { +export interface UserHouseholdAssociationV2UpdateInput { label?: string | null; + householdId?: string; } // ============================================================================ @@ -71,6 +73,13 @@ export function toV2CreateRequest( }; } +export function toV2UpdateRequest(updates: UserHouseholdAssociationV2UpdateInput) { + return { + label: updates.label, + household_id: updates.householdId, + }; +} + /** * Convert v2 API response to app format */ @@ -182,9 +191,10 @@ export async function fetchUserHouseholdAssociationByIdV2( */ export async function updateUserHouseholdAssociationV2( associationId: string, - updates: UserHouseholdAssociationV2UpdateRequest + updates: UserHouseholdAssociationV2UpdateInput ): Promise { const url = `${API_V2_BASE_URL}${BASE_PATH}/${associationId}`; + const body = toV2UpdateRequest(updates); const json = await v2Fetch( url, @@ -195,7 +205,7 @@ export async function updateUserHouseholdAssociationV2( 'Content-Type': 'application/json', Accept: 'application/json', }, - body: JSON.stringify(updates), + body: JSON.stringify(body), } ); return fromV2Response(json); diff --git a/app/src/components/household/HouseholdBreakdown.tsx b/app/src/components/household/HouseholdBreakdown.tsx index 9f38aabdc..bb15a7ed6 100644 --- a/app/src/components/household/HouseholdBreakdown.tsx +++ b/app/src/components/household/HouseholdBreakdown.tsx @@ -1,6 +1,6 @@ import { useSelector } from 'react-redux'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import { RootState } from '@/store'; -import { Household } from '@/types/ingredients/Household'; import VariableArithmetic from './VariableArithmetic'; interface HouseholdBreakdownProps { diff --git a/app/src/components/household/HouseholdBuilderForm.tsx b/app/src/components/household/HouseholdBuilderForm.tsx index 623c97c60..e1528fb90 100644 --- a/app/src/components/household/HouseholdBuilderForm.tsx +++ b/app/src/components/household/HouseholdBuilderForm.tsx @@ -28,7 +28,12 @@ import { Text, } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; -import { Household } from '@/types/ingredients/Household'; +import type { AppHouseholdInputEnvelope } from '@/models/household/appTypes'; +import { + cloneHousehold, + ensureHouseholdGroupInstance, + getPreferredHouseholdGroupName, +} from '@/utils/householdDataAccess'; import { sortPeopleKeys } from '@/utils/householdIndividuals'; import { addVariable, @@ -37,20 +42,21 @@ import { getVariableEntityDisplayInfo, getVariableInfo, removeVariable, + removeVariableFromEntity, resolveEntity, } from '@/utils/VariableResolver'; import VariableRow from './VariableRow'; import VariableSearchDropdown from './VariableSearchDropdown'; export interface HouseholdBuilderFormProps { - household: Household; + household: AppHouseholdInputEnvelope; metadata: any; year: string; maritalStatus: 'single' | 'married'; numChildren: number; basicPersonFields: string[]; // Basic inputs for person entity (e.g., age, employment_income) basicNonPersonFields: string[]; // Basic inputs for household-level entities - onChange: (household: Household) => void; + onChange: (household: AppHouseholdInputEnvelope) => void; onMaritalStatusChange: (status: 'single' | 'married') => void; onNumChildrenChange: (num: number) => void; disabled?: boolean; @@ -138,6 +144,15 @@ export default function HouseholdBuilderForm({ [householdSearchValue, allInputVariables] ); + const resolveDefaultGroupInstanceName = (variableName: string): string | undefined => { + const entityInfo = resolveEntity(variableName, metadata); + if (!entityInfo || entityInfo.isPerson) { + return undefined; + } + + return getPreferredHouseholdGroupName(household.householdData, entityInfo.plural); + }; + // Get variables for a specific person (custom only, not basic inputs) const getPersonVariables = (personName: string): string[] => { const personData = household.householdData.people[personName]; @@ -165,9 +180,14 @@ export default function HouseholdBuilderForm({ }) .map((varName) => { const entityInfo = resolveEntity(varName, metadata); - return { name: varName, entity: entityInfo?.plural || 'households' }; + const entity = entityInfo?.plural || 'households'; + return { + name: varName, + entity, + entityName: getPreferredHouseholdGroupName(household.householdData, entity), + }; }); - }, [selectedVariables, metadata, basicNonPersonFields]); + }, [selectedVariables, metadata, basicNonPersonFields, household]); // Handle opening person search const handleOpenPersonSearch = (person: string) => { @@ -209,12 +229,7 @@ export default function HouseholdBuilderForm({ // Handle removing person variable const handleRemovePersonVariable = (varName: string, person: string) => { - // Remove the variable data from this person's household data - const newHousehold = { ...household }; - const personData = newHousehold.householdData.people[person]; - if (personData && personData[varName]) { - delete personData[varName]; - } + const newHousehold = removeVariableFromEntity(household, varName, metadata, person); onChange(newHousehold); // Check if any other person still has this variable @@ -250,7 +265,7 @@ export default function HouseholdBuilderForm({ setWarningMessage(null); } - let newHousehold: Household; + let newHousehold: AppHouseholdInputEnvelope; if (isPerson) { // For person-level variables selected from household, add to ALL people // Always call addVariable to ensure new members get it too @@ -263,12 +278,17 @@ export default function HouseholdBuilderForm({ setIsHouseholdSearchFocused(false); return; } + const ensuredHousehold = cloneHousehold(household); + const targetEntityName = ensureHouseholdGroupInstance( + ensuredHousehold.householdData, + resolveEntity(variable.name, metadata)?.plural || 'households' + ); newHousehold = addVariableToEntity( - household, + ensuredHousehold, variable.name, metadata, year, - 'your household' + targetEntityName ); } onChange(newHousehold); @@ -481,6 +501,7 @@ export default function HouseholdBuilderForm({ if (!variable) { return null; } + const entityName = resolveDefaultGroupInstanceName(fieldName); return ( { + {householdLevelVariables.map(({ name: varName, entity, entityName }) => { const variable = allInputVariables.find((v) => v.name === varName); if (!variable) { return null; @@ -508,6 +530,7 @@ export default function HouseholdBuilderForm({ household={household} metadata={metadata} year={year} + entityName={entityName} onChange={onChange} onRemove={() => handleRemoveHouseholdVariable(varName)} disabled={disabled} diff --git a/app/src/components/household/HouseholdSummaryCard.tsx b/app/src/components/household/HouseholdSummaryCard.tsx index 381b7f6f2..91a2fd257 100644 --- a/app/src/components/household/HouseholdSummaryCard.tsx +++ b/app/src/components/household/HouseholdSummaryCard.tsx @@ -2,8 +2,8 @@ import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { Stack, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import { RootState } from '@/store'; -import { Household } from '@/types/ingredients/Household'; import { MetadataState } from '@/types/metadata'; import { calculateVariableComparison } from '@/utils/householdComparison'; import { formatVariableValue } from '@/utils/householdValues'; diff --git a/app/src/components/household/VariableArithmetic.tsx b/app/src/components/household/VariableArithmetic.tsx index 0a12e195e..dbd2301a0 100644 --- a/app/src/components/household/VariableArithmetic.tsx +++ b/app/src/components/household/VariableArithmetic.tsx @@ -4,8 +4,8 @@ import { useSelector } from 'react-redux'; import { Button, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { useReportYear } from '@/hooks/useReportYear'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import { RootState } from '@/store'; -import { Household } from '@/types/ingredients/Household'; import { MetadataState } from '@/types/metadata'; import { calculateVariableComparison } from '@/utils/householdComparison'; import { getDisplayStyleConfig } from '@/utils/householdDisplayStyles'; diff --git a/app/src/components/household/VariableInput.tsx b/app/src/components/household/VariableInput.tsx index 832a6207e..73561b2b5 100644 --- a/app/src/components/household/VariableInput.tsx +++ b/app/src/components/household/VariableInput.tsx @@ -17,17 +17,17 @@ import { Text, } from '@/components/ui'; import { colors, typography } from '@/designTokens'; -import { Household } from '@/types/ingredients/Household'; +import type { AppHouseholdInputEnvelope } from '@/models/household/appTypes'; import { coerceByValueType } from '@/utils/valueCoercion'; import { getValue, setValue, VariableInfo } from '@/utils/VariableResolver'; export interface VariableInputProps { variable: VariableInfo; - household: Household; + household: AppHouseholdInputEnvelope; metadata: any; year: string; entityName?: string; // Required for person-level variables - onChange: (newHousehold: Household) => void; + onChange: (newHousehold: AppHouseholdInputEnvelope) => void; disabled?: boolean; } diff --git a/app/src/components/household/VariableRow.tsx b/app/src/components/household/VariableRow.tsx index a906c7a89..d3f0e9bc2 100644 --- a/app/src/components/household/VariableRow.tsx +++ b/app/src/components/household/VariableRow.tsx @@ -4,17 +4,17 @@ import { IconX } from '@tabler/icons-react'; import { Button, Text, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'; -import { Household } from '@/types/ingredients/Household'; +import type { AppHouseholdInputEnvelope } from '@/models/household/appTypes'; import { VariableInfo } from '@/utils/VariableResolver'; import VariableInput from './VariableInput'; export interface VariableRowProps { variable: VariableInfo; - household: Household; + household: AppHouseholdInputEnvelope; metadata: any; year: string; entityName?: string; - onChange: (household: Household) => void; + onChange: (household: AppHouseholdInputEnvelope) => void; onRemove?: () => void; disabled?: boolean; /** Reserve space for remove button column (for alignment with removable rows) */ diff --git a/app/src/hooks/household/replaceHouseholdBaseForAssociation.ts b/app/src/hooks/household/replaceHouseholdBaseForAssociation.ts new file mode 100644 index 000000000..e3cf41334 --- /dev/null +++ b/app/src/hooks/household/replaceHouseholdBaseForAssociation.ts @@ -0,0 +1,71 @@ +import { createHousehold } from '@/api/household'; +import type { UserHouseholdStore } from '@/api/householdAssociation'; +import { + shadowCreateHousehold, + shadowUpdateUserHouseholdAssociation, +} from '@/libs/migration/householdShadow'; +import { Household } from '@/models/Household'; +import type { AppHouseholdInputEnvelope } from '@/models/household/appTypes'; +import type { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; + +function buildAssociationReplacementError(args: { + associationId: string; + createdHouseholdId: string; + cause: unknown; +}): Error { + const { associationId, createdHouseholdId, cause } = args; + const message = + cause instanceof Error ? cause.message : 'Unknown household association update failure'; + + return new Error( + `Failed to update household association ${associationId} after creating replacement household ${createdHouseholdId}. ` + + `The replacement household may now be orphaned. Original error: ${message}` + ); +} + +export async function replaceHouseholdBaseForAssociation(args: { + association: UserHouseholdPopulation; + nextHousehold: AppHouseholdInputEnvelope; + store: Pick; +}): Promise { + const { association, nextHousehold: nextHouseholdInput, store } = args; + + if (!association.id) { + throw new Error( + 'Household association must have an id before its base household can be replaced' + ); + } + + const nextHouseholdModel = Household.fromAppInput({ + ...nextHouseholdInput, + label: nextHouseholdInput.id ? null : (association.label ?? null), + }); + const createdHousehold = await createHousehold(nextHouseholdModel.toV1CreationPayload()); + const nextHouseholdId = String(createdHousehold.result.household_id); + let updatedAssociation: UserHouseholdPopulation; + + try { + updatedAssociation = await store.update(association.id, { + householdId: nextHouseholdId, + }); + } catch (error) { + throw buildAssociationReplacementError({ + associationId: association.id, + createdHouseholdId: nextHouseholdId, + cause: error, + }); + } + + void (async () => { + const persistedHousehold = nextHouseholdModel + .withId(nextHouseholdId) + .withLabel(updatedAssociation.label ?? association.label ?? null); + const v2HouseholdId = await shadowCreateHousehold(nextHouseholdId, persistedHousehold); + await shadowUpdateUserHouseholdAssociation(updatedAssociation, { + previousHouseholdId: association.householdId, + v2HouseholdId, + }); + })(); + + return updatedAssociation; +} diff --git a/app/src/hooks/useCreateHousehold.ts b/app/src/hooks/useCreateHousehold.ts index e3480d646..29b645410 100644 --- a/app/src/hooks/useCreateHousehold.ts +++ b/app/src/hooks/useCreateHousehold.ts @@ -2,6 +2,9 @@ import { useMutation } from '@tanstack/react-query'; import { createHousehold } from '@/api/household'; import { MOCK_USER_ID } from '@/constants'; import { countryIds } from '@/libs/countries'; +import { shadowCreateHouseholdAndAssociation } from '@/libs/migration/householdShadow'; +import { Household } from '@/models/Household'; +import type { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; import { useCreateHouseholdAssociation } from './useUserHousehold'; export function useCreateHousehold(householdLabel?: string) { @@ -10,19 +13,32 @@ export function useCreateHousehold(householdLabel?: string) { const mutation = useMutation({ mutationFn: createHousehold, - onSuccess: async (data, variables) => { + onSuccess: async (data, householdPayload) => { + const resolvedLabel = householdLabel ?? householdPayload.label; + let association: UserHouseholdPopulation | undefined; + try { // Create association with current user (or anonymous for session storage) const userId = MOCK_USER_ID; // TODO: Replace with actual user ID retrieval logic and add conditional logic to access user ID - await createAssociation.mutateAsync({ + + association = await createAssociation.mutateAsync({ userId, householdId: data.result.household_id, // This is from the API response structure; may be modified in API v2 - countryId: variables.country_id as (typeof countryIds)[number], // Use the country from the creation payload - label: householdLabel, + countryId: householdPayload.country_id as (typeof countryIds)[number], // Use the country from the creation payload + label: resolvedLabel, }); } catch (error) { console.error('Household created but association failed:', error); } + + void shadowCreateHouseholdAndAssociation({ + v1HouseholdId: data.result.household_id, + v1Household: Household.fromV1CreationPayload(householdPayload, { + id: data.result.household_id, + label: resolvedLabel ?? null, + }), + v1Association: association, + }); }, }); diff --git a/app/src/hooks/useCreateReport.ts b/app/src/hooks/useCreateReport.ts index 7a7b4d5ef..02b68c4f1 100644 --- a/app/src/hooks/useCreateReport.ts +++ b/app/src/hooks/useCreateReport.ts @@ -4,8 +4,8 @@ import { MOCK_USER_ID } from '@/constants'; import { useCalcOrchestratorManager } from '@/contexts/CalcOrchestratorContext'; import { countryIds } from '@/libs/countries'; import { reportAssociationKeys, reportKeys } from '@/libs/queryKeys'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import { Geography } from '@/types/ingredients/Geography'; -import { Household } from '@/types/ingredients/Household'; import { Simulation } from '@/types/ingredients/Simulation'; import { ReportCreationPayload } from '@/types/payloads'; diff --git a/app/src/hooks/useHouseholdVariation.ts b/app/src/hooks/useHouseholdVariation.ts index 8d91f6230..971b4c2de 100644 --- a/app/src/hooks/useHouseholdVariation.ts +++ b/app/src/hooks/useHouseholdVariation.ts @@ -3,7 +3,7 @@ import { fetchHouseholdById } from '@/api/household'; import { fetchHouseholdVariation } from '@/api/householdVariation'; import { countryIds } from '@/libs/countries'; import { householdVariationKeys } from '@/libs/queryKeys'; -import type { Household } from '@/types/ingredients/Household'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import { buildHouseholdVariationAxes } from '@/utils/householdVariationAxes'; interface UseHouseholdVariationParams { diff --git a/app/src/hooks/useSaveSharedReport.ts b/app/src/hooks/useSaveSharedReport.ts index 7e6c946eb..d1ea3f2d6 100644 --- a/app/src/hooks/useSaveSharedReport.ts +++ b/app/src/hooks/useSaveSharedReport.ts @@ -11,6 +11,10 @@ import { useSelector } from 'react-redux'; import { PolicyAdapter } from '@/adapters/PolicyAdapter'; import { ReportIngredientsInput } from '@/hooks/utils/useFetchReportIngredients'; import { CountryId } from '@/libs/countries'; +import { + shadowCreateHouseholdAndAssociation, + shadowCreateUserHouseholdAssociation, +} from '@/libs/migration/householdShadow'; import { getV2Id } from '@/libs/migration/idMapping'; import { logMigrationConsole } from '@/libs/migration/migrationLogRuntime'; import { sendMigrationLog } from '@/libs/migration/migrationLogTransport'; @@ -18,9 +22,12 @@ import { shadowCreatePolicyAndAssociation, shadowCreateUserPolicyAssociation, } from '@/libs/migration/policyShadow'; +import { Household } from '@/models/Household'; +import type { AppHouseholdInputEnvelope } from '@/models/household/appTypes'; import { RootState } from '@/store'; import { Policy } from '@/types/ingredients/Policy'; import { UserPolicy } from '@/types/ingredients/UserPolicy'; +import { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; import { UserReport } from '@/types/ingredients/UserReport'; import { getShareDataUserReportId } from '@/utils/shareUtils'; import { useCreateGeographicAssociation } from './useUserGeographic'; @@ -30,6 +37,7 @@ import { useCreateReportAssociation, useUserReportStore } from './useUserReportA import { useCreateSimulationAssociation } from './useUserSimulationAssociations'; export type SaveResult = 'success' | 'partial' | 'already_saved' | null; +type SharedSaveHouseholdDetails = AppHouseholdInputEnvelope | Household; function shadowSavedPolicyAssociation(association: UserPolicy, policyDetails?: Policy): void { const mappedV2PolicyId = getV2Id('Policy', association.policyId); @@ -71,6 +79,55 @@ function shadowSavedPolicyAssociation(association: UserPolicy, policyDetails?: P }); } +function shadowSavedHouseholdAssociation( + association: UserHouseholdPopulation, + householdDetails?: SharedSaveHouseholdDetails +): void { + const mappedV2HouseholdId = getV2Id('Household', association.householdId); + + if (mappedV2HouseholdId) { + void shadowCreateUserHouseholdAssociation(association, mappedV2HouseholdId); + return; + } + + if (!householdDetails) { + logMigrationConsole( + '[HouseholdMigration] Shared save missing household details; skipping shadow v2 household create:', + association.householdId + ); + sendMigrationLog({ + kind: 'event', + prefix: 'HouseholdMigration', + operation: 'CREATE', + status: 'SKIPPED', + message: 'Shared save missing household details; skipping shadow v2 household create', + metadata: { + householdId: association.householdId, + countryId: association.countryId, + }, + ts: new Date().toISOString(), + }); + return; + } + + const v1Household = + householdDetails instanceof Household + ? householdDetails + .withId(association.householdId) + .withLabel(association.label ?? householdDetails.label ?? null) + : Household.fromAppInput({ + ...householdDetails, + id: association.householdId, + label: association.label ?? householdDetails.label ?? null, + }); + + void shadowCreateHouseholdAndAssociation({ + v1HouseholdId: association.householdId, + v1Household, + v1Association: association, + }); +} + /** * Hook for saving a shared report and all its user associations to localStorage * @@ -107,11 +164,15 @@ export function useSaveSharedReport() { const saveSharedReport = async ( shareData: ReportIngredientsInput, - policies: Policy[] = [] + policies: Policy[] = [], + households: SharedSaveHouseholdDetails[] = [] ): Promise => { const userId = 'anonymous'; // TODO: Replace with auth context const userReportId = getShareDataUserReportId(shareData); const policiesById = new Map(policies.map((policy) => [String(policy.id), policy])); + const householdsById = new Map( + households.map((household) => [String(household.id), household]) + ); // Idempotency check: see if this report is already saved const existingReport = await reportStore.findByUserReportId(userReportId); @@ -146,14 +207,17 @@ export function useSaveSharedReport() { }); // Save households - const householdPromises = shareData.userHouseholds.map((hh) => - createHouseholdAssociation.mutateAsync({ + const householdPromises = shareData.userHouseholds.map(async (hh) => { + const association = await createHouseholdAssociation.mutateAsync({ userId, householdId: hh.householdId, countryId: hh.countryId as CountryId, label: hh.label ?? undefined, - }) - ); + }); + + shadowSavedHouseholdAssociation(association, householdsById.get(String(hh.householdId))); + return association; + }); // Save geographies const geographyPromises = shareData.userGeographies.map((geo) => diff --git a/app/src/hooks/useUserHousehold.ts b/app/src/hooks/useUserHousehold.ts index 5179fb09e..287becde8 100644 --- a/app/src/hooks/useUserHousehold.ts +++ b/app/src/hooks/useUserHousehold.ts @@ -1,9 +1,13 @@ // Import auth hook here in future; for now, mocked out below import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import { fetchHouseholdById } from '@/api/household'; +import type { UserHouseholdStore } from '@/api/householdAssociation'; +import { replaceHouseholdBaseForAssociation as replaceHouseholdBaseForAssociationAction } from '@/hooks/household/replaceHouseholdBaseForAssociation'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { shadowUpdateUserHouseholdAssociation } from '@/libs/migration/householdShadow'; +import { Household } from '@/models/Household'; +import type { AppHouseholdInputEnvelope } from '@/models/household/appTypes'; import { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; -import { HouseholdMetadata } from '@/types/metadata/householdMetadata'; import { ApiHouseholdStore, LocalStorageHouseholdStore } from '../api/householdAssociation'; import { queryConfig } from '../libs/queryConfig'; import { householdAssociationKeys, householdKeys } from '../libs/queryKeys'; @@ -70,20 +74,52 @@ export const useCreateHouseholdAssociation = () => { }); }; +export async function replaceHouseholdBaseForAssociation(args: { + association: UserHouseholdPopulation; + nextHousehold: AppHouseholdInputEnvelope; + store?: Pick; +}): Promise { + return replaceHouseholdBaseForAssociationAction({ + ...args, + store: args.store ?? localHouseholdStore, + }); +} + export const useUpdateHouseholdAssociation = () => { const store = useUserHouseholdStore(); const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ + mutationFn: async ({ userHouseholdId, updates, + association, + nextHousehold, }: { userHouseholdId: string; updates: Partial; - }) => store.update(userHouseholdId, updates), + association?: UserHouseholdPopulation; + nextHousehold?: AppHouseholdInputEnvelope; + }) => { + if (nextHousehold) { + if (!association) { + throw new Error('Association is required when replacing a household base'); + } + + return replaceHouseholdBaseForAssociation({ + association, + nextHousehold, + store, + }); + } + + return store.update(userHouseholdId, updates); + }, + + onSuccess: (updatedAssociation, variables) => { + const previousHouseholdId = + variables.association?.householdId ?? updatedAssociation.householdId; - onSuccess: (updatedAssociation) => { // Invalidate all related queries to trigger refetch queryClient.invalidateQueries({ queryKey: householdAssociationKeys.byUser( @@ -93,8 +129,13 @@ export const useUpdateHouseholdAssociation = () => { }); queryClient.invalidateQueries({ - queryKey: householdAssociationKeys.byHousehold(updatedAssociation.householdId), + queryKey: householdAssociationKeys.byHousehold(previousHouseholdId), }); + if (previousHouseholdId !== updatedAssociation.householdId) { + queryClient.invalidateQueries({ + queryKey: householdAssociationKeys.byHousehold(updatedAssociation.householdId), + }); + } // Optimistically update caches queryClient.setQueryData( @@ -104,6 +145,20 @@ export const useUpdateHouseholdAssociation = () => { ), updatedAssociation ); + + if (previousHouseholdId !== updatedAssociation.householdId) { + queryClient.removeQueries({ + queryKey: householdAssociationKeys.specific( + updatedAssociation.userId, + previousHouseholdId + ), + exact: true, + }); + } + + if (!variables.nextHousehold) { + void shadowUpdateUserHouseholdAssociation(updatedAssociation); + } }, }); }; @@ -133,7 +188,7 @@ export const useDeleteAssociation = () => { // Type for the combined data structure export interface UserHouseholdMetadataWithAssociation { association: UserHouseholdPopulation; - household: HouseholdMetadata | undefined; + household: Household | undefined; isLoading: boolean; error: Error | null | undefined; isError?: boolean; @@ -170,7 +225,10 @@ export const useUserHouseholds = (userId: string) => { const householdQueries = useQueries({ queries: householdIds.map((householdId) => ({ queryKey: householdKeys.byId(householdId), - queryFn: () => fetchHouseholdById(country, householdId), + queryFn: async () => { + const metadata = await fetchHouseholdById(country, householdId); + return Household.fromV1Metadata(metadata); + }, enabled: !!associations, // Only run when associations are loaded staleTime: 5 * 60 * 1000, })), diff --git a/app/src/hooks/useUserReports.ts b/app/src/hooks/useUserReports.ts index ac5650d2f..c16907e4f 100644 --- a/app/src/hooks/useUserReports.ts +++ b/app/src/hooks/useUserReports.ts @@ -1,15 +1,15 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; -import { HouseholdAdapter, PolicyAdapter, ReportAdapter, SimulationAdapter } from '@/adapters'; +import { PolicyAdapter, ReportAdapter, SimulationAdapter } from '@/adapters'; import { fetchHouseholdById } from '@/api/household'; import { fetchPolicyById } from '@/api/policy'; import { fetchReportById } from '@/api/report'; import { fetchSimulationById } from '@/api/simulation'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { GC_TIME_5_MIN } from '@/libs/queryConfig'; +import { Household as HouseholdModel } from '@/models/Household'; import { RootState } from '@/store'; import { Geography } from '@/types/ingredients/Geography'; -import { Household } from '@/types/ingredients/Household'; import { Policy } from '@/types/ingredients/Policy'; import { Report } from '@/types/ingredients/Report'; import { Simulation } from '@/types/ingredients/Simulation'; @@ -43,7 +43,7 @@ export interface EnhancedUserReport { // Related entities from simulations policies?: Policy[]; - households?: Household[]; + households?: HouseholdModel[]; geographies?: Geography[]; // User associations for related entities @@ -165,11 +165,11 @@ export const useUserReports = (userId: string) => { }); // Step 8: Fetch households - const householdResults = useParallelQueries(householdIds, { + const householdResults = useParallelQueries(householdIds, { queryKey: householdKeys.byId, queryFn: async (id) => { const metadata = await fetchHouseholdById(country, id); - return HouseholdAdapter.fromMetadata(metadata); + return HouseholdModel.fromV1Metadata(metadata); }, enabled: householdIds.length > 0, staleTime: 5 * 60 * 1000, @@ -214,7 +214,7 @@ export const useUserReports = (userId: string) => { ]; // Get households and geographies from simulations - const reportHouseholds: Household[] = []; + const reportHouseholds: HouseholdModel[] = []; const reportGeographies: Geography[] = []; reportSimulations.forEach((sim) => { @@ -319,7 +319,7 @@ export const useUserReports = (userId: string) => { queryClient.getQueryData(simulationKeys.byId(id)), getNormalizedPolicy: (id: string) => queryClient.getQueryData(policyKeys.byId(id)), getNormalizedHousehold: (id: string) => - queryClient.getQueryData(householdKeys.byId(id)), + queryClient.getQueryData(householdKeys.byId(id)), }; }; @@ -443,17 +443,19 @@ export const useUserReportById = (userReportId: string, options?: { enabled?: bo const householdSimulations = simulations.filter((s) => s.populationType === 'household'); const householdIds = extractUniqueIds(householdSimulations, 'populationId'); - const householdResults = useParallelQueries(householdIds, { + const householdResults = useParallelQueries(householdIds, { queryKey: householdKeys.byId, queryFn: async (id) => { const metadata = await fetchHouseholdById(country, id); - return HouseholdAdapter.fromMetadata(metadata); + return HouseholdModel.fromV1Metadata(metadata); }, enabled: isEnabled && householdIds.length > 0, staleTime: 5 * 60 * 1000, }); - const households = householdResults.queries.map((q) => q.data).filter((h): h is Household => !!h); + const households = householdResults.queries + .map((q) => q.data) + .filter((h): h is HouseholdModel => !!h); const userHouseholds = householdAssociations?.filter((ha) => households.some((h) => h.id === ha.householdId) diff --git a/app/src/hooks/useUserSimulations.ts b/app/src/hooks/useUserSimulations.ts index ba134dc1e..e4e33a342 100644 --- a/app/src/hooks/useUserSimulations.ts +++ b/app/src/hooks/useUserSimulations.ts @@ -1,13 +1,14 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; -import { HouseholdAdapter, PolicyAdapter, SimulationAdapter } from '@/adapters'; +import { PolicyAdapter, SimulationAdapter } from '@/adapters'; import { fetchHouseholdById } from '@/api/household'; import { fetchPolicyById } from '@/api/policy'; import { fetchSimulationById } from '@/api/simulation'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { Household as HouseholdModel } from '@/models/Household'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import { RootState } from '@/store'; import { Geography } from '@/types/ingredients/Geography'; -import { Household } from '@/types/ingredients/Household'; import { Policy } from '@/types/ingredients/Policy'; import { Simulation } from '@/types/ingredients/Simulation'; import { UserPolicy } from '@/types/ingredients/UserPolicy'; @@ -128,7 +129,7 @@ export const useUserSimulations = (userId: string) => { queryKey: householdKeys.byId, queryFn: async (id) => { const metadata = await fetchHouseholdById(country, id); - return HouseholdAdapter.fromMetadata(metadata); + return HouseholdModel.fromV1Metadata(metadata); }, enabled: householdIds.length > 0, staleTime: 5 * 60 * 1000, @@ -290,7 +291,7 @@ export const useUserSimulationById = (userId: string, simulationId: string) => { queryKey: householdKeys.byId(populationId ?? ''), queryFn: async () => { const metadata = await fetchHouseholdById(country, populationId!); - return HouseholdAdapter.fromMetadata(metadata); + return HouseholdModel.fromV1Metadata(metadata); }, enabled: !!populationId, staleTime: 5 * 60 * 1000, diff --git a/app/src/hooks/utils/useFetchReportIngredients.ts b/app/src/hooks/utils/useFetchReportIngredients.ts index 3c482a6b4..bbae6892a 100644 --- a/app/src/hooks/utils/useFetchReportIngredients.ts +++ b/app/src/hooks/utils/useFetchReportIngredients.ts @@ -12,7 +12,7 @@ */ import { useSelector } from 'react-redux'; -import { HouseholdAdapter, PolicyAdapter, ReportAdapter, SimulationAdapter } from '@/adapters'; +import { PolicyAdapter, ReportAdapter, SimulationAdapter } from '@/adapters'; import { fetchHouseholdById } from '@/api/household'; import { fetchPolicyById } from '@/api/policy'; import { fetchReportById } from '@/api/report'; @@ -20,9 +20,10 @@ import { fetchSimulationById } from '@/api/simulation'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { GC_TIME_5_MIN } from '@/libs/queryConfig'; import { householdKeys, policyKeys, reportKeys, simulationKeys } from '@/libs/queryKeys'; +import { Household as HouseholdModel } from '@/models/Household'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import { RootState } from '@/store'; import { Geography } from '@/types/ingredients/Geography'; -import { Household } from '@/types/ingredients/Household'; import { Policy } from '@/types/ingredients/Policy'; import { Report } from '@/types/ingredients/Report'; import { Simulation } from '@/types/ingredients/Simulation'; @@ -255,7 +256,7 @@ export function useFetchReportIngredients( queryKey: householdKeys.byId, queryFn: async (id) => { const metadata = await fetchHouseholdById(country, id); - return HouseholdAdapter.fromMetadata(metadata); + return HouseholdModel.fromV1Metadata(metadata); }, enabled: isEnabled && householdIds.length > 0, staleTime: 5 * 60 * 1000, diff --git a/app/src/libs/calculations/ProgressTracker.ts b/app/src/libs/calculations/ProgressTracker.ts index 857329597..2eb0cb864 100644 --- a/app/src/libs/calculations/ProgressTracker.ts +++ b/app/src/libs/calculations/ProgressTracker.ts @@ -1,4 +1,4 @@ -import { HouseholdData } from '@/types/ingredients/Household'; +import type { AppHouseholdInputData as HouseholdData } from '@/models/household/appTypes'; import { ProgressInfo } from './strategies/types'; /** diff --git a/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts b/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts index 1e02b8f1d..773e7e169 100644 --- a/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts +++ b/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts @@ -2,8 +2,8 @@ import type { QueryClient } from '@tanstack/react-query'; import { markReportCompleted, markReportError as persistReportError } from '@/api/report'; import { markSimulationError, updateSimulationOutput } from '@/api/simulation'; import { reportKeys, simulationKeys } from '@/libs/queryKeys'; +import type { AppHouseholdInputData as HouseholdData } from '@/models/household/appTypes'; import type { HouseholdReportConfig, SimulationConfig } from '@/types/calculation/household'; -import type { HouseholdData } from '@/types/ingredients/Household'; import type { Report } from '@/types/ingredients/Report'; import { cacheMonitor } from '@/utils/cacheMonitor'; import { HouseholdProgressCoordinator } from './HouseholdProgressCoordinator'; diff --git a/app/src/libs/calculations/household/householdReportUtils.ts b/app/src/libs/calculations/household/householdReportUtils.ts index b6c26ab3d..cb9b04f6f 100644 --- a/app/src/libs/calculations/household/householdReportUtils.ts +++ b/app/src/libs/calculations/household/householdReportUtils.ts @@ -1,5 +1,5 @@ +import type { AppHouseholdInputData as HouseholdData } from '@/models/household/appTypes'; import type { HouseholdReportOutput } from '@/types/calculation/household'; -import type { HouseholdData } from '@/types/ingredients/Household'; /** * Build household report output from simulation results diff --git a/app/src/libs/migration/householdShadow.ts b/app/src/libs/migration/householdShadow.ts new file mode 100644 index 000000000..366821265 --- /dev/null +++ b/app/src/libs/migration/householdShadow.ts @@ -0,0 +1,288 @@ +import { createHouseholdV2 } from '@/api/v2'; +import { + createUserHouseholdAssociationV2, + fetchUserHouseholdAssociationByIdV2, + updateUserHouseholdAssociationV2, +} from '@/api/v2/userHouseholdAssociations'; +import { Household } from '@/models/Household'; +import type { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; +import { logMigrationComparison } from './comparisonLogger'; +import { + clearV2AssociationTargetId, + getOrCreateV2UserId, + getV2AssociationTargetId, + getV2Id, + setV2AssociationTargetId, + setV2Id, +} from './idMapping'; +import { logMigrationConsole } from './migrationLogRuntime'; +import { sendMigrationLog } from './migrationLogTransport'; + +function buildComparableUserHouseholdAssociation( + v1Association: UserHouseholdPopulation, + v2UserId: string, + v2HouseholdId: string +): UserHouseholdPopulation { + return { + ...v1Association, + userId: v2UserId, + householdId: v2HouseholdId, + }; +} + +function logSkippedUserHouseholdUpdate( + v1Association: UserHouseholdPopulation, + message: string, + metadata?: Record +): void { + logMigrationConsole(`[UserHouseholdMigration] ${message}`); + sendMigrationLog({ + kind: 'event', + prefix: 'UserHouseholdMigration', + operation: 'UPDATE', + status: 'SKIPPED', + message, + metadata: { + associationId: v1Association.id ?? null, + householdId: v1Association.householdId, + userId: v1Association.userId, + ...metadata, + }, + ts: new Date().toISOString(), + }); +} + +export async function shadowCreateUserHouseholdAssociation( + v1Association: UserHouseholdPopulation, + v2HouseholdId = getV2Id('Household', v1Association.householdId) +): Promise { + if (!v2HouseholdId) { + return; + } + + try { + const v2UserId = getOrCreateV2UserId(v1Association.userId); + const v2Result = await createUserHouseholdAssociationV2({ + userId: v2UserId, + householdId: v2HouseholdId, + countryId: v1Association.countryId, + label: v1Association.label, + }); + + if (v1Association.id && v2Result.id) { + setV2Id('UserHousehold', v1Association.id, v2Result.id); + setV2AssociationTargetId( + 'UserHousehold', + v1Association.id, + v1Association.householdId, + v2Result.id + ); + } + + logMigrationComparison( + 'UserHouseholdMigration', + 'CREATE', + buildComparableUserHouseholdAssociation( + v1Association, + v2UserId, + v2HouseholdId + ) as unknown as Record, + v2Result as unknown as Record, + { skipFields: ['id', 'createdAt', 'updatedAt', 'isCreated'] } + ); + } catch (error) { + logMigrationConsole('[UserHouseholdMigration] Shadow v2 create failed (non-blocking):', error); + sendMigrationLog({ + kind: 'event', + prefix: 'UserHouseholdMigration', + operation: 'CREATE', + status: 'FAILED', + message: 'Shadow v2 create failed (non-blocking)', + metadata: { + householdId: v1Association.householdId, + userId: v1Association.userId, + error: error instanceof Error ? error.message : String(error), + }, + ts: new Date().toISOString(), + }); + } +} + +export async function shadowCreateHouseholdAndAssociation(args: { + v1HouseholdId: string; + v1Household: Household; + v1Association?: UserHouseholdPopulation; +}): Promise { + const { v1HouseholdId, v1Household, v1Association } = args; + + const v2HouseholdId = await shadowCreateHousehold(v1HouseholdId, v1Household); + + if (v1Association && v2HouseholdId) { + await shadowCreateUserHouseholdAssociation(v1Association, v2HouseholdId); + } + + return v2HouseholdId; +} + +export async function shadowCreateHousehold( + v1HouseholdId: string, + v1Household: Household +): Promise { + try { + const v2CreateEnvelope = v1Household.toV2CreateEnvelope(); + const v2Household = Household.fromV2Response(await createHouseholdV2(v2CreateEnvelope)); + + setV2Id('Household', v1HouseholdId, v2Household.id); + logMigrationComparison( + 'HouseholdMigration', + 'CREATE', + v1Household.toComparable() as unknown as Record, + v2Household.toComparable() as unknown as Record, + { skipFields: ['id'] } + ); + return v2Household.id; + } catch (error) { + logMigrationConsole( + '[HouseholdMigration] Shadow v2 household create failed (non-blocking):', + error + ); + sendMigrationLog({ + kind: 'event', + prefix: 'HouseholdMigration', + operation: 'CREATE', + status: 'FAILED', + message: 'Shadow v2 household create failed (non-blocking)', + metadata: { + householdId: v1HouseholdId, + countryId: v1Household.countryId, + error: error instanceof Error ? error.message : String(error), + }, + ts: new Date().toISOString(), + }); + return null; + } +} + +export async function shadowUpdateUserHouseholdAssociation( + v1Association: UserHouseholdPopulation, + options: { + previousHouseholdId?: string; + v2HouseholdId?: string | null; + v1Household?: Household; + } = {} +): Promise { + if (!v1Association.id) { + return; + } + + const v2UserId = getOrCreateV2UserId(v1Association.userId); + let resolvedV2HouseholdId = + options.v2HouseholdId ?? getV2Id('Household', v1Association.householdId); + + if (!resolvedV2HouseholdId && options.v1Household) { + resolvedV2HouseholdId = await shadowCreateHousehold( + v1Association.householdId, + options.v1Household + ); + } + + if (!resolvedV2HouseholdId) { + logSkippedUserHouseholdUpdate( + v1Association, + 'Shadow v2 update skipped: missing mapped v2 household id' + ); + return; + } + + try { + let v2UserHouseholdId = getV2Id('UserHousehold', v1Association.id); + const lookupV1HouseholdId = options.previousHouseholdId ?? v1Association.householdId; + + if (!v2UserHouseholdId) { + v2UserHouseholdId = getV2AssociationTargetId( + 'UserHousehold', + v1Association.id, + lookupV1HouseholdId + ); + + if (v2UserHouseholdId) { + setV2Id('UserHousehold', v1Association.id, v2UserHouseholdId); + } else { + const lookupV2HouseholdId = + getV2Id('Household', lookupV1HouseholdId) ?? resolvedV2HouseholdId; + const existingV2Association = await fetchUserHouseholdAssociationByIdV2( + v2UserId, + lookupV2HouseholdId + ); + + if (existingV2Association?.id) { + setV2Id('UserHousehold', v1Association.id, existingV2Association.id); + setV2AssociationTargetId( + 'UserHousehold', + v1Association.id, + lookupV1HouseholdId, + existingV2Association.id + ); + v2UserHouseholdId = existingV2Association.id; + } else { + await shadowCreateUserHouseholdAssociation(v1Association, resolvedV2HouseholdId); + const recreatedV2AssociationId = getV2Id('UserHousehold', v1Association.id); + + if (!recreatedV2AssociationId) { + logSkippedUserHouseholdUpdate( + v1Association, + 'Shadow v2 update skipped: failed to recreate missing v2 association', + { v2HouseholdId: resolvedV2HouseholdId } + ); + } + + return; + } + } + } + + const v2Result = await updateUserHouseholdAssociationV2(v2UserHouseholdId, { + label: v1Association.label ?? null, + householdId: resolvedV2HouseholdId, + }); + + setV2AssociationTargetId( + 'UserHousehold', + v1Association.id, + v1Association.householdId, + v2UserHouseholdId + ); + + if (options.previousHouseholdId && options.previousHouseholdId !== v1Association.householdId) { + clearV2AssociationTargetId('UserHousehold', v1Association.id, options.previousHouseholdId); + } + + logMigrationComparison( + 'UserHouseholdMigration', + 'UPDATE', + buildComparableUserHouseholdAssociation( + v1Association, + v2UserId, + resolvedV2HouseholdId + ) as unknown as Record, + v2Result as unknown as Record, + { skipFields: ['id', 'createdAt', 'updatedAt', 'isCreated'] } + ); + } catch (error) { + logMigrationConsole('[UserHouseholdMigration] Shadow v2 update failed (non-blocking):', error); + sendMigrationLog({ + kind: 'event', + prefix: 'UserHouseholdMigration', + operation: 'UPDATE', + status: 'FAILED', + message: 'Shadow v2 update failed (non-blocking)', + metadata: { + householdId: v1Association.householdId, + userId: v1Association.userId, + associationId: v1Association.id, + error: error instanceof Error ? error.message : String(error), + }, + ts: new Date().toISOString(), + }); + } +} diff --git a/app/src/libs/migration/idMapping.ts b/app/src/libs/migration/idMapping.ts index dbd2be793..c04c4b25c 100644 --- a/app/src/libs/migration/idMapping.ts +++ b/app/src/libs/migration/idMapping.ts @@ -13,12 +13,17 @@ import { logMigrationConsole } from './migrationLogRuntime'; import { sendMigrationLog } from './migrationLogTransport'; const KEY_PREFIX = 'v1v2'; +const TARGET_KEY_PREFIX = 'v1v2-target'; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; function storageKey(entityType: string, v1Id: string): string { return `${KEY_PREFIX}:${entityType.toLowerCase()}:${v1Id}`; } +function targetStorageKey(entityType: string, v1AssociationId: string, v1TargetId: string): string { + return `${TARGET_KEY_PREFIX}:${entityType.toLowerCase()}:${v1AssociationId}:${v1TargetId}`; +} + function createUuid(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); @@ -80,13 +85,105 @@ export function getV2Id(entityType: string, v1Id: string): string | null { } } +export function setV2AssociationTargetId( + entityType: string, + v1AssociationId: string, + v1TargetId: string, + v2Id: string +): void { + try { + localStorage.setItem(targetStorageKey(entityType, v1AssociationId, v1TargetId), v2Id); + } catch (error) { + logMigrationConsole( + `[${entityType}Migration] Failed to store target mapping: ${v1AssociationId}/${v1TargetId} → ${v2Id}`, + error + ); + sendMigrationLog({ + kind: 'event', + prefix: `${entityType}Migration`, + status: 'FAILED', + message: 'Failed to store target mapping', + metadata: { + entityType, + v1AssociationId, + v1TargetId, + v2Id, + error: error instanceof Error ? error.message : String(error), + }, + ts: new Date().toISOString(), + }); + } +} + +export function getV2AssociationTargetId( + entityType: string, + v1AssociationId: string, + v1TargetId: string +): string | null { + try { + return localStorage.getItem(targetStorageKey(entityType, v1AssociationId, v1TargetId)); + } catch (error) { + logMigrationConsole( + `[${entityType}Migration] Failed to read target mapping for ${v1AssociationId}/${v1TargetId}`, + error + ); + sendMigrationLog({ + kind: 'event', + prefix: `${entityType}Migration`, + status: 'FAILED', + message: 'Failed to read target mapping', + metadata: { + entityType, + v1AssociationId, + v1TargetId, + error: error instanceof Error ? error.message : String(error), + }, + ts: new Date().toISOString(), + }); + return null; + } +} + +export function clearV2AssociationTargetId( + entityType: string, + v1AssociationId: string, + v1TargetId: string +): void { + try { + localStorage.removeItem(targetStorageKey(entityType, v1AssociationId, v1TargetId)); + } catch (error) { + logMigrationConsole( + `[${entityType}Migration] Failed to clear target mapping for ${v1AssociationId}/${v1TargetId}`, + error + ); + sendMigrationLog({ + kind: 'event', + prefix: `${entityType}Migration`, + status: 'FAILED', + message: 'Failed to clear target mapping', + metadata: { + entityType, + v1AssociationId, + v1TargetId, + error: error instanceof Error ? error.message : String(error), + }, + ts: new Date().toISOString(), + }); + } +} + export function clearV2Mappings(entityType?: string): void { try { - const prefix = entityType ? `${KEY_PREFIX}:${entityType.toLowerCase()}:` : `${KEY_PREFIX}:`; + const prefixes = entityType + ? [ + `${KEY_PREFIX}:${entityType.toLowerCase()}:`, + `${TARGET_KEY_PREFIX}:${entityType.toLowerCase()}:`, + ] + : [`${KEY_PREFIX}:`, `${TARGET_KEY_PREFIX}:`]; const keysToRemove: string[] = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); - if (key?.startsWith(prefix)) { + if (key && prefixes.some((prefix) => key.startsWith(prefix))) { keysToRemove.push(key); } } diff --git a/app/src/models/Household.ts b/app/src/models/Household.ts index 9e089c906..0ed2c1b31 100644 --- a/app/src/models/Household.ts +++ b/app/src/models/Household.ts @@ -1,113 +1,221 @@ -import type { HouseholdV2Response } from '@/api/v2/households'; import type { CountryId } from '@/libs/countries'; import { BaseModel } from './BaseModel'; +import { + buildAppHouseholdDataFromV1Data, + buildV1CreateEnvelopeFromAppInput, + cloneAppHouseholdInputData, +} from './household/appCodec'; +import type { + AppHouseholdInputData, + AppHouseholdInputEnvelope, + ComparableHousehold, + HouseholdModelData, +} from './household/appTypes'; +import { buildComparableHousehold } from './household/comparable'; +import { cloneValue, deepEqual, inferYearFromData, normalizeCountryId } from './household/utils'; +import type { V1HouseholdCreateEnvelope, V1HouseholdMetadataEnvelope } from './household/v1Types'; +import { buildV2CreateEnvelope, parseV2HouseholdEnvelope } from './household/v2Codec'; +import type { V2CreateHouseholdEnvelope, V2StoredHouseholdEnvelope } from './household/v2Types'; + +export type { ComparableHousehold, HouseholdModelData } from './household/appTypes'; + +export class Household extends BaseModel { + readonly id: string; -interface HouseholdData { - id: string; - countryId: CountryId; - label: string | null; - year: number | null; - data: Record; -} + private readonly countryIdValue: CountryId; -export class Household extends BaseModel { - readonly id: string; - readonly countryId: CountryId; - readonly year: number | null; + private readonly labelValue: string | null; + + private readonly yearValue: number | null; - private _label: string | null; - private _data: Record; + private readonly appInputData: AppHouseholdInputData; - constructor(data: HouseholdData) { + private constructor(args: { + id: string; + countryId: CountryId; + label?: string | null; + year?: number | null; + appInputData: AppHouseholdInputData; + }) { super(); - if (!data.id) { + + if (!args.id) { throw new Error('Household requires an id'); } - this.id = data.id; - this.countryId = data.countryId; - this.year = data.year; - this._label = data.label; - this._data = data.data; + + this.id = args.id; + this.countryIdValue = normalizeCountryId(args.countryId); + this.labelValue = args.label ?? null; + this.yearValue = args.year ?? null; + this.appInputData = cloneAppHouseholdInputData(args.appInputData); } - // --- Getters --- + get countryId(): CountryId { + return this.countryIdValue; + } get label(): string | null { - return this._label; + return this.labelValue; } - get data(): Record { - return { ...this._data }; + + get year(): number | null { + return this.yearValue; } - get people(): Record { - return (this._data.people as Record) ?? {}; + get householdData(): AppHouseholdInputData { + return cloneValue(this.appInputData); + } + + get people(): AppHouseholdInputData['people'] { + return cloneValue(this.appInputData.people); } get personCount(): number { - const p = this._data.people; - if (Array.isArray(p)) { - return p.length; - } - if (p && typeof p === 'object') { - return Object.keys(p).length; - } - return 0; + return Object.keys(this.appInputData.people).length; } get personNames(): string[] { - const p = this._data.people; - if (Array.isArray(p)) { - return p.map((_, i) => String(i)); - } - if (p && typeof p === 'object') { - return Object.keys(p as Record); - } - return []; + return Object.keys(this.appInputData.people); + } + + static fromAppInput(input: AppHouseholdInputEnvelope): Household { + const appInputData = cloneAppHouseholdInputData(input.householdData); + const year = input.year ?? inferYearFromData(appInputData); + + return new Household({ + id: input.id ?? 'draft-household', + countryId: normalizeCountryId(input.countryId), + label: input.label ?? null, + year, + appInputData, + }); } - // --- Setters --- + static fromDraft(args: { + countryId: AppHouseholdInputEnvelope['countryId']; + householdData: AppHouseholdInputData; + label?: string | null; + year?: number | null; + id?: string; + }): Household { + return Household.fromAppInput({ + id: args.id, + countryId: args.countryId, + householdData: args.householdData, + label: args.label, + year: args.year, + }); + } - set label(value: string | null) { - this._label = value; + static fromV1Metadata(metadata: V1HouseholdMetadataEnvelope): Household { + const appInputData = buildAppHouseholdDataFromV1Data(metadata.household_json); + const year = inferYearFromData(appInputData); + + return Household.fromAppInput({ + id: String(metadata.id), + countryId: metadata.country_id as CountryId, + label: metadata.label ?? null, + year, + householdData: appInputData, + }); } - // --- Factories --- + static fromV1CreationPayload( + payload: V1HouseholdCreateEnvelope, + options: { + id?: string; + label?: string | null; + } = {} + ): Household { + const appInputData = buildAppHouseholdDataFromV1Data(payload.data); + const year = inferYearFromData(appInputData); + + return Household.fromAppInput({ + id: options.id ?? 'draft-household', + countryId: payload.country_id as CountryId, + label: options.label ?? payload.label ?? null, + year, + householdData: appInputData, + }); + } - static fromV2Response(response: HouseholdV2Response): Household { - return new Household({ + static fromV2Response(response: V2StoredHouseholdEnvelope): Household { + return Household.fromAppInput({ id: response.id, - countryId: response.country_id as CountryId, - label: response.label ?? null, - year: response.year ?? null, - data: { - people: response.people, - tax_unit: response.tax_unit, - family: response.family, - spm_unit: response.spm_unit, - marital_unit: response.marital_unit, - household: response.household, - benunit: response.benunit, - }, + ...parseV2HouseholdEnvelope(response), }); } - // --- Serialization --- + static fromV2CreateEnvelope(envelope: V2CreateHouseholdEnvelope): Household { + return Household.fromAppInput({ + id: 'draft-household', + ...parseV2HouseholdEnvelope(envelope), + }); + } + + withId(id: string): Household { + return new Household({ + id, + countryId: this.countryId, + label: this.label, + year: this.year, + appInputData: this.appInputData, + }); + } + + withLabel(label: string | null): Household { + return new Household({ + id: this.id, + countryId: this.countryId, + label, + year: this.year, + appInputData: this.appInputData, + }); + } + + toAppInput(): AppHouseholdInputEnvelope { + return { + id: this.id, + countryId: this.countryId, + label: this.label, + year: this.year, + householdData: this.householdData, + }; + } + + toV1CreationPayload(): V1HouseholdCreateEnvelope { + return buildV1CreateEnvelopeFromAppInput({ + countryId: this.countryId, + householdData: this.appInputData, + label: this.label, + year: this.year ?? inferYearFromData(this.appInputData), + }); + } + + toV2CreateEnvelope(): V2CreateHouseholdEnvelope { + return buildV2CreateEnvelope({ + countryId: this.countryId, + label: this.label, + year: this.year, + householdData: this.appInputData, + }); + } + + toComparable(): ComparableHousehold { + return buildComparableHousehold({ id: this.id, envelope: this.toV2CreateEnvelope() }); + } - toJSON(): HouseholdData { + toJSON(): HouseholdModelData { return { id: this.id, countryId: this.countryId, - label: this._label, + label: this.label, year: this.year, - data: { ...this._data }, + householdData: cloneValue(this.appInputData), }; } isEqual(other: Household): boolean { - return ( - this.id === other.id && - this._label === other._label && - JSON.stringify(this._data) === JSON.stringify(other._data) - ); + return deepEqual(this.toJSON(), other.toJSON()); } } diff --git a/app/src/models/household/appCodec.ts b/app/src/models/household/appCodec.ts new file mode 100644 index 000000000..bfc1addf4 --- /dev/null +++ b/app/src/models/household/appCodec.ts @@ -0,0 +1,199 @@ +import type { + AppHouseholdInputData, + AppHouseholdInputEnvelope, + AppHouseholdInputGroup, + AppHouseholdInputGroupMap, + HouseholdFieldValue, +} from './appTypes'; +import { parseNamedPeople, validateNamedGroupCollection } from './namedCodecHelpers'; +import { GROUP_DEFINITIONS, KNOWN_APP_ENTITY_KEYS, KNOWN_V1_ENTITY_KEYS } from './schema'; +import { + cloneValue, + isYearValueMap, + normalizeCountryId, + normalizeHouseholdFieldValue, + wrapForYear, +} from './utils'; +import type { + V1HouseholdCreateEnvelope, + V1HouseholdData, + V1HouseholdGroupData, + V1HouseholdPersonData, +} from './v1Types'; + +function assertKnownAppEntityKeys(rawData: Record): void { + const unknownKeys = Object.keys(rawData).filter((key) => !KNOWN_APP_ENTITY_KEYS.has(key)); + + if (unknownKeys.length > 0) { + throw new Error(`Unsupported household entities: ${unknownKeys.join(', ')}`); + } +} + +export function cloneAppHouseholdInputData( + householdData: AppHouseholdInputData +): AppHouseholdInputData { + const rawData = householdData as unknown as Record; + assertKnownAppEntityKeys(rawData); + const people = parseNamedPeople(rawData.people, 'Household input'); + const peopleNames = new Set(Object.keys(people)); + + for (const definition of GROUP_DEFINITIONS) { + validateNamedGroupCollection({ + rawGroupCollection: rawData[definition.appKey], + context: 'Household input', + groupKey: definition.appKey, + peopleNames, + }); + } + + return cloneValue(householdData); +} + +export function buildAppHouseholdDataFromV1Data( + householdData: V1HouseholdData +): AppHouseholdInputData { + const rawData = householdData as unknown as Record; + const unknownKeys = Object.keys(rawData).filter((key) => !KNOWN_V1_ENTITY_KEYS.has(key)); + + if (unknownKeys.length > 0) { + throw new Error(`Unsupported household entities in v1 payload: ${unknownKeys.join(', ')}`); + } + + const people: AppHouseholdInputData['people'] = Object.fromEntries( + Object.entries(householdData.people).map(([personName, rawPerson]) => { + const personData = Object.fromEntries( + Object.entries(rawPerson).filter(([, fieldValue]) => fieldValue !== undefined) + ) as AppHouseholdInputData['people'][string]; + + return [personName, personData]; + }) + ); + + const toAppGroupMap = ( + groupMap: Record | undefined + ): AppHouseholdInputGroupMap | undefined => { + if (!groupMap) { + return undefined; + } + + return Object.fromEntries( + Object.entries(groupMap).map(([groupName, rawGroup]) => { + const { members, ...rawValues } = rawGroup; + + return [ + groupName, + { + members: cloneValue(members), + ...Object.fromEntries( + Object.entries(rawValues).filter(([, fieldValue]) => fieldValue !== undefined) + ), + }, + ]; + }) + ); + }; + + return { + people, + households: toAppGroupMap(householdData.households), + families: toAppGroupMap(householdData.families), + taxUnits: toAppGroupMap(householdData.tax_units), + spmUnits: toAppGroupMap(householdData.spm_units), + maritalUnits: toAppGroupMap(householdData.marital_units), + benunits: toAppGroupMap(householdData.benunits), + }; +} + +function buildV1FieldValueFromAppInput( + value: HouseholdFieldValue | string[], + year: number | null, + context: string +): Record { + if (Array.isArray(value)) { + throw new Error(`${context} cannot serialize array values into a v1 field`); + } + + const normalizedValue = normalizeHouseholdFieldValue(value, context); + if (isYearValueMap(normalizedValue)) { + return cloneValue(normalizedValue); + } + if (year === null) { + throw new Error(`${context} requires a year to emit a v1 payload`); + } + + return wrapForYear(normalizedValue, year); +} + +function buildV1GroupMapFromAppInput( + groupMap: Record | undefined, + year: number | null, + context: string +): Record | undefined { + if (!groupMap) { + return undefined; + } + + return Object.fromEntries( + Object.entries(groupMap).map(([groupName, rawGroup]) => { + const { members, ...rawValues } = rawGroup; + const groupData: V1HouseholdGroupData = { + members: cloneValue(members), + ...Object.fromEntries( + Object.entries(rawValues).map(([fieldKey, fieldValue]) => [ + fieldKey, + buildV1FieldValueFromAppInput(fieldValue, year, `${context}.${groupName}.${fieldKey}`), + ]) + ), + }; + + return [groupName, groupData]; + }) + ); +} + +export function buildV1CreateEnvelopeFromAppInput(args: { + countryId: AppHouseholdInputEnvelope['countryId']; + householdData: AppHouseholdInputData; + label?: string | null; + year?: number | null; +}): V1HouseholdCreateEnvelope { + const clonedData = cloneAppHouseholdInputData(args.householdData); + const year = args.year ?? null; + + return { + country_id: normalizeCountryId(args.countryId), + label: args.label ?? undefined, + data: { + people: Object.fromEntries( + Object.entries(clonedData.people).map(([personName, rawPerson]) => { + const personData: V1HouseholdPersonData = Object.fromEntries( + Object.entries(rawPerson).map(([fieldKey, fieldValue]) => [ + fieldKey, + buildV1FieldValueFromAppInput( + fieldValue, + year, + `Household input.people.${personName}.${fieldKey}` + ), + ]) + ); + + return [personName, personData]; + }) + ), + households: buildV1GroupMapFromAppInput( + clonedData.households, + year, + 'Household input.households' + ), + families: buildV1GroupMapFromAppInput(clonedData.families, year, 'Household input.families'), + tax_units: buildV1GroupMapFromAppInput(clonedData.taxUnits, year, 'Household input.taxUnits'), + spm_units: buildV1GroupMapFromAppInput(clonedData.spmUnits, year, 'Household input.spmUnits'), + marital_units: buildV1GroupMapFromAppInput( + clonedData.maritalUnits, + year, + 'Household input.maritalUnits' + ), + benunits: buildV1GroupMapFromAppInput(clonedData.benunits, year, 'Household input.benunits'), + }, + }; +} diff --git a/app/src/models/household/appTypes.ts b/app/src/models/household/appTypes.ts new file mode 100644 index 000000000..341aeff10 --- /dev/null +++ b/app/src/models/household/appTypes.ts @@ -0,0 +1,85 @@ +import type { CountryId } from '@/libs/countries'; + +export type HouseholdScalar = string | number | boolean | null; +export type HouseholdYearValueMap = Record; +export type HouseholdFieldValue = HouseholdScalar | HouseholdYearValueMap; + +export interface AppHouseholdInputEnvelope { + id?: string; + countryId: CountryId; + householdData: AppHouseholdInputData; + label?: string | null; + year?: number | null; +} + +export interface AppHouseholdInputPerson { + [key: string]: HouseholdFieldValue; +} + +export interface AppHouseholdInputGroup { + members: string[]; + [key: string]: HouseholdFieldValue | string[]; +} + +export type AppHouseholdInputGroupMap = Record; + +export interface AppHouseholdInputData { + people: Record; + households?: AppHouseholdInputGroupMap; + families?: AppHouseholdInputGroupMap; + taxUnits?: AppHouseholdInputGroupMap; + spmUnits?: AppHouseholdInputGroupMap; + maritalUnits?: AppHouseholdInputGroupMap; + benunits?: AppHouseholdInputGroupMap; +} + +export interface AppUSHouseholdInputData { + people: Record; + households?: AppHouseholdInputGroupMap; + families?: AppHouseholdInputGroupMap; + taxUnits?: AppHouseholdInputGroupMap; + spmUnits?: AppHouseholdInputGroupMap; + maritalUnits?: AppHouseholdInputGroupMap; +} + +export interface AppUKHouseholdInputData { + people: Record; + households?: AppHouseholdInputGroupMap; + benunits?: AppHouseholdInputGroupMap; +} + +export interface AppUSHouseholdInputEnvelope { + id?: string; + countryId: 'us'; + householdData: AppUSHouseholdInputData; + label?: string | null; + year?: number | null; +} + +export interface AppUKHouseholdInputEnvelope { + id?: string; + countryId: 'uk'; + householdData: AppUKHouseholdInputData; + label?: string | null; + year?: number | null; +} + +export type SupportedV2AppHouseholdInputEnvelope = + | AppUSHouseholdInputEnvelope + | AppUKHouseholdInputEnvelope; + +export interface HouseholdModelData { + id: string; + countryId: CountryId; + label: string | null; + year: number | null; + householdData: AppHouseholdInputData; +} + +export interface ComparableHousehold { + id: string; + countryId: CountryId; + year: number | null; + label: string | null; + data: Record; +} diff --git a/app/src/models/household/comparable.ts b/app/src/models/household/comparable.ts new file mode 100644 index 000000000..bf5148e3d --- /dev/null +++ b/app/src/models/household/comparable.ts @@ -0,0 +1,32 @@ +import type { ComparableHousehold } from './appTypes'; +import { sortRecordKeysRecursively } from './utils'; +import type { V2CreateHouseholdEnvelope } from './v2Types'; + +export function buildComparableHousehold(args: { + id: string; + envelope: V2CreateHouseholdEnvelope; +}): ComparableHousehold { + const comparableData = + args.envelope.country_id === 'us' + ? { + people: args.envelope.people, + tax_unit: args.envelope.tax_unit, + family: args.envelope.family, + spm_unit: args.envelope.spm_unit, + marital_unit: args.envelope.marital_unit, + household: args.envelope.household, + } + : { + people: args.envelope.people, + household: args.envelope.household, + benunit: args.envelope.benunit, + }; + + return { + id: args.id, + countryId: args.envelope.country_id, + year: args.envelope.year, + label: args.envelope.label ?? null, + data: sortRecordKeysRecursively(comparableData) as Record, + }; +} diff --git a/app/src/models/household/namedCodecHelpers.ts b/app/src/models/household/namedCodecHelpers.ts new file mode 100644 index 000000000..822c9dd0b --- /dev/null +++ b/app/src/models/household/namedCodecHelpers.ts @@ -0,0 +1,88 @@ +import type { HouseholdFieldValue } from './appTypes'; +import { isRecord, normalizeHouseholdFieldMap } from './utils'; + +export function parseNamedPeople( + rawPeople: unknown, + context: string +): Record }> { + if (rawPeople === undefined) { + return {}; + } + + if (!isRecord(rawPeople)) { + throw new Error(`${context}: people must be an object`); + } + + return Object.fromEntries( + Object.entries(rawPeople).map(([personName, rawPerson]) => { + if (!isRecord(rawPerson)) { + throw new Error(`${context}: person "${personName}" must be an object`); + } + + return [ + personName, + { + values: normalizeHouseholdFieldMap(rawPerson, `${context}: person "${personName}"`), + }, + ]; + }) + ); +} + +export function validateNamedGroupCollection(args: { + rawGroupCollection: unknown; + context: string; + groupKey: string; + peopleNames: Set; +}): void { + if (args.rawGroupCollection === undefined) { + return; + } + + if (!isRecord(args.rawGroupCollection)) { + throw new Error(`${args.context}: ${args.groupKey} must be an object`); + } + + for (const [groupName, rawGroup] of Object.entries(args.rawGroupCollection)) { + parseNamedGroup({ + groupName, + rawGroup, + context: args.context, + groupKey: args.groupKey, + peopleNames: args.peopleNames, + }); + } +} + +function parseNamedGroup(args: { + groupName: string; + rawGroup: unknown; + context: string; + groupKey: string; + peopleNames: Set; +}): { name?: string; members: string[]; values: Record } { + const { groupName, rawGroup } = args; + if (!isRecord(rawGroup)) { + throw new Error(`${args.context}: ${args.groupKey}.${groupName} must be an object`); + } + + if (!Array.isArray(rawGroup.members)) { + throw new Error(`${args.context}: ${args.groupKey}.${groupName}.members must be an array`); + } + + const members = rawGroup.members.map((member) => String(member)); + const unknownMembers = members.filter((member) => !args.peopleNames.has(member)); + if (unknownMembers.length > 0) { + throw new Error( + `${args.context}: ${args.groupKey}.${groupName} references unknown members: ${unknownMembers.join(', ')}` + ); + } + + const { members: _ignoredMembers, ...rawValues } = rawGroup; + + return { + name: groupName, + members, + values: normalizeHouseholdFieldMap(rawValues, `${args.context}: ${args.groupKey}.${groupName}`), + }; +} diff --git a/app/src/models/household/schema.ts b/app/src/models/household/schema.ts new file mode 100644 index 000000000..e690dccdc --- /dev/null +++ b/app/src/models/household/schema.ts @@ -0,0 +1,133 @@ +export type HouseholdGroupAppKey = + | 'households' + | 'families' + | 'taxUnits' + | 'spmUnits' + | 'maritalUnits' + | 'benunits'; + +export type HouseholdGroupV1Key = + | 'households' + | 'families' + | 'tax_units' + | 'spm_units' + | 'marital_units' + | 'benunits'; + +export type HouseholdGroupV2Key = + | 'household' + | 'family' + | 'tax_unit' + | 'spm_unit' + | 'marital_unit' + | 'benunit'; + +export type V2CountryId = 'us' | 'uk'; + +export interface HouseholdGroupDefinition { + appKey: HouseholdGroupAppKey; + v1Key: HouseholdGroupV1Key; + v2Key: HouseholdGroupV2Key; + personLinkKey: string; + groupIdKey: string; + generatedKeyPrefix: string; +} + +export const GROUP_DEFINITIONS: readonly HouseholdGroupDefinition[] = [ + { + appKey: 'households', + v1Key: 'households', + v2Key: 'household', + personLinkKey: 'person_household_id', + groupIdKey: 'household_id', + generatedKeyPrefix: 'household', + }, + { + appKey: 'families', + v1Key: 'families', + v2Key: 'family', + personLinkKey: 'person_family_id', + groupIdKey: 'family_id', + generatedKeyPrefix: 'family', + }, + { + appKey: 'taxUnits', + v1Key: 'tax_units', + v2Key: 'tax_unit', + personLinkKey: 'person_tax_unit_id', + groupIdKey: 'tax_unit_id', + generatedKeyPrefix: 'taxUnit', + }, + { + appKey: 'spmUnits', + v1Key: 'spm_units', + v2Key: 'spm_unit', + personLinkKey: 'person_spm_unit_id', + groupIdKey: 'spm_unit_id', + generatedKeyPrefix: 'spmUnit', + }, + { + appKey: 'maritalUnits', + v1Key: 'marital_units', + v2Key: 'marital_unit', + personLinkKey: 'person_marital_unit_id', + groupIdKey: 'marital_unit_id', + generatedKeyPrefix: 'maritalUnit', + }, + { + appKey: 'benunits', + v1Key: 'benunits', + v2Key: 'benunit', + personLinkKey: 'person_benunit_id', + groupIdKey: 'benunit_id', + generatedKeyPrefix: 'benunit', + }, +] as const; + +export const V2_GROUP_DEFINITIONS_BY_COUNTRY: Record< + V2CountryId, + readonly HouseholdGroupDefinition[] +> = { + us: GROUP_DEFINITIONS.filter((definition) => definition.appKey !== 'benunits'), + uk: GROUP_DEFINITIONS.filter( + (definition) => definition.appKey === 'households' || definition.appKey === 'benunits' + ), +}; + +const GROUP_DEFINITION_BY_APP_KEY = new Map( + GROUP_DEFINITIONS.map((definition) => [definition.appKey, definition]) +); +const GROUP_DEFINITION_BY_V1_KEY = new Map( + GROUP_DEFINITIONS.map((definition) => [definition.v1Key, definition]) +); + +export const KNOWN_APP_ENTITY_KEYS = new Set([ + 'people', + ...GROUP_DEFINITIONS.map((definition) => definition.appKey), +]); + +export const KNOWN_V1_ENTITY_KEYS = new Set([ + 'people', + ...GROUP_DEFINITIONS.map((definition) => definition.v1Key), +]); + +export const PERSON_LINK_KEYS = new Set( + GROUP_DEFINITIONS.map((definition) => definition.personLinkKey) +); +export const PERSON_META_KEYS = new Set(['name', 'person_id', ...PERSON_LINK_KEYS]); + +export function getGroupDefinitionByAppKey(key: string): HouseholdGroupDefinition | undefined { + return GROUP_DEFINITION_BY_APP_KEY.get(key as HouseholdGroupAppKey); +} + +export function getGroupDefinitionByV1Key(key: string): HouseholdGroupDefinition | undefined { + return GROUP_DEFINITION_BY_V1_KEY.get(key as HouseholdGroupV1Key); +} + +export function buildGeneratedGroupName(prefix: string, index: number): string { + return `${prefix}${index + 1}`; +} + +export function getV2GroupDefinitions(countryId: V2CountryId): readonly HouseholdGroupDefinition[] { + return V2_GROUP_DEFINITIONS_BY_COUNTRY[countryId]; +} diff --git a/app/src/models/household/utils.ts b/app/src/models/household/utils.ts new file mode 100644 index 000000000..61b5d95a2 --- /dev/null +++ b/app/src/models/household/utils.ts @@ -0,0 +1,216 @@ +import { countryIds, type CountryId } from '@/libs/countries'; +import type { HouseholdFieldValue, HouseholdScalar, HouseholdYearValueMap } from './appTypes'; + +export type HouseholdFieldMap = Record; + +export function cloneValue(value: T): T { + if (typeof structuredClone === 'function') { + return structuredClone(value); + } + return JSON.parse(JSON.stringify(value)) as T; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function normalizeCountryId(countryId: string): CountryId { + if (!countryIds.includes(countryId as CountryId)) { + throw new Error(`Unknown country_id "${countryId}". Expected one of: ${countryIds.join(', ')}`); + } + return countryId as CountryId; +} + +export function isHouseholdScalar(value: unknown): value is HouseholdScalar { + return value === null || ['string', 'number', 'boolean'].includes(typeof value); +} + +export function camelToSnake(value: string): string { + return value.replace(/([A-Z])/g, '_$1').toLowerCase(); +} + +export function snakeToCamel(value: string): string { + return value.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()); +} + +export function isYearKey(value: string): boolean { + return /^\d{4}$/.test(value); +} + +export function isYearValueMap(value: unknown): value is HouseholdYearValueMap { + if (!isRecord(value)) { + return false; + } + + const keys = Object.keys(value); + return keys.length > 0 && keys.every(isYearKey) && Object.values(value).every(isHouseholdScalar); +} + +export function normalizeHouseholdFieldValue(value: unknown, context: string): HouseholdFieldValue { + if (isHouseholdScalar(value)) { + return value; + } + + if (isYearValueMap(value)) { + return cloneValue(value); + } + + throw new Error(`${context} must be a scalar or year-keyed scalar map`); +} + +export function normalizeHouseholdFieldMap(values: unknown, context: string): HouseholdFieldMap { + if (!isRecord(values)) { + throw new Error(`${context} must be an object`); + } + + return Object.fromEntries( + Object.entries(values) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => [key, normalizeHouseholdFieldValue(value, `${context}.${key}`)]) + ); +} + +export function inferYearFromData(value: unknown): number | null { + const years = new Set(); + + const visit = (nested: unknown) => { + if (Array.isArray(nested)) { + nested.forEach(visit); + return; + } + + if (!isRecord(nested)) { + return; + } + + const keys = Object.keys(nested); + if (keys.length > 0 && keys.every(isYearKey)) { + keys.forEach((key) => years.add(Number(key))); + return; + } + + Object.values(nested).forEach(visit); + }; + + visit(value); + return years.size > 0 ? Math.max(...years) : null; +} + +function selectYearValue( + value: Record, + preferredYear: number | null +): HouseholdScalar { + if (preferredYear !== null && String(preferredYear) in value) { + return value[String(preferredYear)]; + } + + const sortedKeys = Object.keys(value).sort(); + return value[sortedKeys[sortedKeys.length - 1]]; +} + +export function flattenForYear( + value: HouseholdFieldValue, + preferredYear: number | null +): HouseholdScalar { + if (isYearValueMap(value)) { + return selectYearValue(value, preferredYear); + } + return value; +} + +export function wrapForYear(value: HouseholdScalar, year: number): HouseholdYearValueMap { + return { [String(year)]: value }; +} + +export function flattenEntityValues( + values: HouseholdFieldMap, + preferredYear: number | null +): Record { + const flattened: Record = {}; + + for (const [key, value] of Object.entries(values)) { + if (value === undefined) { + continue; + } + flattened[key] = flattenForYear(value, preferredYear); + } + + return flattened; +} + +export function wrapEntityValuesForYear( + values: Record, + year: number +): HouseholdFieldMap { + const wrapped: HouseholdFieldMap = {}; + + for (const [key, value] of Object.entries(values)) { + if (value === undefined) { + continue; + } + + const normalizedValue = normalizeHouseholdFieldValue( + value, + `Year-wrapped entity field "${key}"` + ); + wrapped[key] = isYearValueMap(normalizedValue) + ? normalizedValue + : wrapForYear(normalizedValue, year); + } + + return wrapped; +} + +export function omitRecordKeys( + record: Record, + omittedKeys: Iterable +): Record { + const omitted = new Set(omittedKeys); + + return Object.fromEntries( + Object.entries(record).filter(([key, value]) => !omitted.has(key) && value !== undefined) + ); +} + +export function sortRecordKeysRecursively(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortRecordKeysRecursively); + } + + if (!isRecord(value)) { + return value; + } + + return Object.fromEntries( + Object.entries(value) + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) + .map(([key, nestedValue]) => [key, sortRecordKeysRecursively(nestedValue)]) + ); +} + +export function deepEqual(left: unknown, right: unknown): boolean { + if (left === right) { + return true; + } + + if (Array.isArray(left) && Array.isArray(right)) { + if (left.length !== right.length) { + return false; + } + + return left.every((entry, index) => deepEqual(entry, right[index])); + } + + if (isRecord(left) && isRecord(right)) { + const leftKeys = Object.keys(left).sort(); + const rightKeys = Object.keys(right).sort(); + + if (!deepEqual(leftKeys, rightKeys)) { + return false; + } + + return leftKeys.every((key) => deepEqual(left[key], right[key])); + } + + return false; +} diff --git a/app/src/models/household/v1Types.ts b/app/src/models/household/v1Types.ts new file mode 100644 index 000000000..c7283d99a --- /dev/null +++ b/app/src/models/household/v1Types.ts @@ -0,0 +1,42 @@ +import type { HouseholdScalar } from './appTypes'; + +export interface V1HouseholdMetadataEnvelope { + id: string; + country_id: string; + label?: string | null; + api_version: string; + household_json: V1HouseholdData; + household_hash: string; +} + +export interface V1HouseholdData { + people: Record; + families?: Record; + tax_units?: Record; + spm_units?: Record; + households?: Record; + marital_units?: Record; + benunits?: Record; +} + +export interface V1HouseholdPersonData { + [key: string]: Record | undefined; +} + +export interface V1HouseholdMemberGroup { + members: string[]; +} + +export interface V1HouseholdGroupProperties { + [key: string]: Record | undefined; +} + +export type V1HouseholdGroupData = V1HouseholdMemberGroup & { + [key: string]: Record | string[] | undefined; +}; + +export interface V1HouseholdCreateEnvelope { + country_id: string; + data: V1HouseholdData; + label?: string; +} diff --git a/app/src/models/household/v2Codec.ts b/app/src/models/household/v2Codec.ts new file mode 100644 index 000000000..44999c5bc --- /dev/null +++ b/app/src/models/household/v2Codec.ts @@ -0,0 +1,456 @@ +import type { CountryId } from '@/libs/countries'; +import { cloneAppHouseholdInputData } from './appCodec'; +import type { AppHouseholdInputData, HouseholdFieldValue } from './appTypes'; +import { + buildGeneratedGroupName, + getV2GroupDefinitions, + type HouseholdGroupDefinition, +} from './schema'; +import { + flattenEntityValues, + inferYearFromData, + isRecord, + normalizeCountryId, + omitRecordKeys, + wrapEntityValuesForYear, +} from './utils'; +import type { + V2CreateHouseholdEnvelope, + V2HouseholdEnvelope, + V2HouseholdGroupData, + V2HouseholdPersonData, + V2SupportedCountryId, + V2UKCreateHouseholdEnvelope, + V2UKHouseholdPersonData, + V2USCreateHouseholdEnvelope, + V2USHouseholdPersonData, +} from './v2Types'; + +function normalizeV2CountryId(countryId: string): V2SupportedCountryId { + const normalized = normalizeCountryId(countryId); + + if (normalized !== 'us' && normalized !== 'uk') { + throw new Error(`V2 household country "${normalized}" is not supported`); + } + + return normalized; +} + +function coerceV2GroupRows(value: unknown): V2HouseholdGroupData[] { + if (value == null) { + return []; + } + + if (Array.isArray(value)) { + return value; + } + + if (isRecord(value)) { + return [value]; + } + + throw new Error('V2 household group rows must be arrays when present'); +} + +function buildAppPeopleFromV2Envelope(args: { + people: V2HouseholdEnvelope['people']; + year: number; + countryId: V2SupportedCountryId; +}): { + people: AppHouseholdInputData['people']; + personNameById: Map; +} { + const people: AppHouseholdInputData['people'] = {}; + const personNameById = new Map(); + const usedNames = new Set(); + const groupDefinitions = getV2GroupDefinitions(args.countryId); + + for (const [index, rawPerson] of args.people.entries()) { + const person = isRecord(rawPerson) ? rawPerson : {}; + const personId = typeof person.person_id === 'number' ? person.person_id : index; + const explicitName = + typeof person.name === 'string' && person.name.length > 0 ? person.name : null; + + if (explicitName && usedNames.has(explicitName)) { + throw new Error(`Duplicate person name "${explicitName}" in v2 household response`); + } + + let personName = explicitName ?? `person_${personId}`; + while (usedNames.has(personName)) { + personName = `${personName}_${index + 1}`; + } + + usedNames.add(personName); + personNameById.set(personId, personName); + people[personName] = wrapEntityValuesForYear( + omitRecordKeys(person, [ + 'name', + 'person_id', + ...groupDefinitions.map((definition) => definition.personLinkKey), + ]), + args.year + ); + } + + return { people, personNameById }; +} + +function buildGroupMembersFromV2People(args: { + people: V2HouseholdEnvelope['people']; + personNameById: Map; + personLinkKey: string; + groupId: number; + groupLabel: string; +}): string[] { + const members: string[] = []; + + for (const [index, rawPerson] of args.people.entries()) { + const person = isRecord(rawPerson) ? rawPerson : {}; + const linkedGroupId = + typeof person[args.personLinkKey] === 'number' + ? (person[args.personLinkKey] as number) + : null; + const personId = typeof person.person_id === 'number' ? (person.person_id as number) : index; + + if (linkedGroupId !== args.groupId) { + continue; + } + + const personName = args.personNameById.get(personId); + if (!personName) { + throw new Error(`${args.groupLabel}: linked person ${personId} could not be resolved`); + } + + members.push(personName); + } + + return members; +} + +function hasExplicitPersonLinkAssignments(args: { + people: V2HouseholdEnvelope['people']; + personLinkKey: string; +}): boolean { + return args.people.some((rawPerson) => { + const person = isRecord(rawPerson) ? rawPerson : {}; + return typeof person[args.personLinkKey] === 'number'; + }); +} + +function parseV2GroupCollection(args: { + envelope: V2HouseholdEnvelope; + peopleNames: string[]; + personNameById: Map; + definition: HouseholdGroupDefinition; + year: number; +}): AppHouseholdInputData[typeof args.definition.appKey] | undefined { + const groupRows = coerceV2GroupRows( + (args.envelope as unknown as Record)[args.definition.v2Key] + ); + if (groupRows.length === 0) { + return undefined; + } + + const hasExplicitLinks = hasExplicitPersonLinkAssignments({ + people: args.envelope.people, + personLinkKey: args.definition.personLinkKey, + }); + + if (groupRows.length > 1 && !hasExplicitLinks) { + throw new Error( + `V2 household ${args.definition.v2Key} has multiple rows but people do not include ${args.definition.personLinkKey}` + ); + } + + const groupMap: NonNullable = {}; + + groupRows.forEach((rawGroup, index) => { + if (!isRecord(rawGroup)) { + throw new Error(`V2 household ${args.definition.v2Key} rows must be objects`); + } + + const groupId = rawGroup[args.definition.groupIdKey]; + let members: string[]; + + if (typeof groupId === 'number') { + members = buildGroupMembersFromV2People({ + people: args.envelope.people, + personNameById: args.personNameById, + personLinkKey: args.definition.personLinkKey, + groupId, + groupLabel: args.definition.v2Key, + }); + + if (members.length === 0) { + if (!hasExplicitLinks && groupRows.length === 1) { + members = [...args.peopleNames]; + } else { + throw new Error( + `V2 household ${args.definition.v2Key} has no linked members for ${args.definition.groupIdKey}=${groupId}` + ); + } + } + } else if (hasExplicitLinks) { + throw new Error( + `V2 household ${args.definition.v2Key} is missing numeric ${args.definition.groupIdKey}` + ); + } else { + members = [...args.peopleNames]; + } + + groupMap[buildGeneratedGroupName(args.definition.generatedKeyPrefix, index)] = { + members, + ...(wrapEntityValuesForYear( + omitRecordKeys(rawGroup, [args.definition.groupIdKey]), + args.year + ) as Record), + }; + }); + + return groupMap; +} + +export function parseV2HouseholdEnvelope(envelope: V2HouseholdEnvelope): { + countryId: V2SupportedCountryId; + label: string | null; + year: number; + householdData: AppHouseholdInputData; +} { + const countryId = normalizeV2CountryId(envelope.country_id); + const year = envelope.year; + const { people, personNameById } = buildAppPeopleFromV2Envelope({ + people: envelope.people, + year, + countryId, + }); + const peopleNames = Object.keys(people); + + const householdData: AppHouseholdInputData = { people }; + + for (const definition of getV2GroupDefinitions(countryId)) { + const parsedGroupCollection = parseV2GroupCollection({ + envelope, + peopleNames, + personNameById, + definition, + year, + }); + + if (parsedGroupCollection) { + householdData[definition.appKey] = parsedGroupCollection; + } + } + + return { + countryId, + label: envelope.label ?? null, + year, + householdData, + }; +} + +function buildV2PeopleFromAppInput(args: { + householdData: AppHouseholdInputData; + year: number; + countryId: V2SupportedCountryId; +}): V2HouseholdPersonData[] { + const personNames = Object.keys(args.householdData.people).sort((left, right) => + left.localeCompare(right) + ); + const personNameSet = new Set(personNames); + const personAssignments = new Map>(); + + for (const definition of getV2GroupDefinitions(args.countryId)) { + const groupMap = args.householdData[definition.appKey]; + if (!groupMap) { + continue; + } + + const sortedGroups = Object.entries(groupMap).sort(([left], [right]) => + left.localeCompare(right) + ); + + sortedGroups.forEach(([groupName, group], groupIndex) => { + if (group.members.length === 0) { + throw new Error( + `Household ${definition.appKey}.${groupName} must have at least one member` + ); + } + + const unknownMembers = group.members.filter((member) => !personNameSet.has(member)); + if (unknownMembers.length > 0) { + throw new Error( + `Household ${definition.appKey}.${groupName} references unknown members: ${unknownMembers.join(', ')}` + ); + } + + group.members.forEach((member) => { + const currentAssignments = personAssignments.get(member) ?? {}; + currentAssignments[definition.personLinkKey] = groupIndex; + personAssignments.set(member, currentAssignments); + }); + }); + } + + return personNames.map((personName, personIndex) => ({ + name: personName, + person_id: personIndex, + ...personAssignments.get(personName), + ...flattenEntityValues(args.householdData.people[personName], args.year), + })); +} + +function buildV2GroupRowsFromAppInput(args: { + groupMap: NonNullable< + AppHouseholdInputData[keyof Pick< + AppHouseholdInputData, + 'households' | 'families' | 'taxUnits' | 'spmUnits' | 'maritalUnits' | 'benunits' + >] + >; + definition: HouseholdGroupDefinition; + year: number; +}): V2HouseholdGroupData[] { + return Object.entries(args.groupMap) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, group], groupIndex) => { + const groupValues = omitRecordKeys(group, ['members']) as Record; + + return { + [args.definition.groupIdKey]: groupIndex, + ...flattenEntityValues(groupValues, args.year), + }; + }); +} + +function buildUSCreateEnvelope(args: { + label: string | null; + year: number; + householdData: AppHouseholdInputData; +}): V2USCreateHouseholdEnvelope { + const envelope: V2USCreateHouseholdEnvelope = { + country_id: 'us', + year: args.year, + label: args.label, + people: buildV2PeopleFromAppInput({ + householdData: args.householdData, + year: args.year, + countryId: 'us', + }) as V2USHouseholdPersonData[], + household: [], + marital_unit: [], + family: [], + spm_unit: [], + tax_unit: [], + }; + + for (const definition of getV2GroupDefinitions('us')) { + const groupMap = args.householdData[definition.appKey]; + if (!groupMap) { + continue; + } + + const groupRows = buildV2GroupRowsFromAppInput({ + groupMap, + definition, + year: args.year, + }); + + switch (definition.v2Key) { + case 'household': + envelope.household = groupRows; + break; + case 'marital_unit': + envelope.marital_unit = groupRows; + break; + case 'family': + envelope.family = groupRows; + break; + case 'spm_unit': + envelope.spm_unit = groupRows; + break; + case 'tax_unit': + envelope.tax_unit = groupRows; + break; + default: + throw new Error(`Unsupported US v2 household group key "${definition.v2Key}"`); + } + } + + return envelope; +} + +function buildUKCreateEnvelope(args: { + label: string | null; + year: number; + householdData: AppHouseholdInputData; +}): V2UKCreateHouseholdEnvelope { + const envelope: V2UKCreateHouseholdEnvelope = { + country_id: 'uk', + year: args.year, + label: args.label, + people: buildV2PeopleFromAppInput({ + householdData: args.householdData, + year: args.year, + countryId: 'uk', + }) as V2UKHouseholdPersonData[], + household: [], + benunit: [], + }; + + for (const definition of getV2GroupDefinitions('uk')) { + const groupMap = args.householdData[definition.appKey]; + if (!groupMap) { + continue; + } + + const groupRows = buildV2GroupRowsFromAppInput({ + groupMap, + definition, + year: args.year, + }); + + switch (definition.v2Key) { + case 'household': + envelope.household = groupRows; + break; + case 'benunit': + envelope.benunit = groupRows; + break; + default: + throw new Error(`Unsupported UK v2 household group key "${definition.v2Key}"`); + } + } + + return envelope; +} + +export function buildV2CreateEnvelope(args: { + countryId: CountryId; + label: string | null; + year: number | null; + householdData: AppHouseholdInputData; +}): V2CreateHouseholdEnvelope { + const householdData = cloneAppHouseholdInputData(args.householdData); + const year = args.year ?? inferYearFromData(householdData); + + if (year === null) { + throw new Error('Household requires a year to convert to a v2 create envelope'); + } + + const countryId = normalizeV2CountryId(args.countryId); + + switch (countryId) { + case 'us': + return buildUSCreateEnvelope({ + label: args.label, + year, + householdData, + }); + case 'uk': + return buildUKCreateEnvelope({ + label: args.label, + year, + householdData, + }); + } +} diff --git a/app/src/models/household/v2Types.ts b/app/src/models/household/v2Types.ts new file mode 100644 index 000000000..61c860964 --- /dev/null +++ b/app/src/models/household/v2Types.ts @@ -0,0 +1,106 @@ +import type { CountryId } from '@/libs/countries'; + +export type V2SupportedCountryId = Extract; + +export interface V2BaseHouseholdPersonData { + name?: string; + person_id?: number; + [key: string]: unknown; +} + +export interface V2USHouseholdPersonData extends V2BaseHouseholdPersonData { + person_household_id?: number; + person_family_id?: number; + person_tax_unit_id?: number; + person_spm_unit_id?: number; + person_marital_unit_id?: number; +} + +export interface V2UKHouseholdPersonData extends V2BaseHouseholdPersonData { + person_household_id?: number; + person_benunit_id?: number; +} + +export type V2HouseholdPersonData = V2USHouseholdPersonData | V2UKHouseholdPersonData; + +export interface V2HouseholdGroupData { + [key: string]: unknown; +} + +interface V2BaseHouseholdData { + year: number; + label?: string | null; + people: TPerson[]; + household: V2HouseholdGroupData[]; +} + +export interface V2USHouseholdData extends V2BaseHouseholdData { + marital_unit: V2HouseholdGroupData[]; + family: V2HouseholdGroupData[]; + spm_unit: V2HouseholdGroupData[]; + tax_unit: V2HouseholdGroupData[]; +} + +export interface V2UKHouseholdData extends V2BaseHouseholdData { + benunit: V2HouseholdGroupData[]; +} + +export interface V2USCreateHouseholdEnvelope extends V2USHouseholdData { + country_id: 'us'; +} + +export interface V2UKCreateHouseholdEnvelope extends V2UKHouseholdData { + country_id: 'uk'; +} + +export type V2CreateHouseholdEnvelope = V2USCreateHouseholdEnvelope | V2UKCreateHouseholdEnvelope; + +export interface V2USStoredHouseholdEnvelope extends V2USCreateHouseholdEnvelope { + id: string; + created_at: string; + updated_at: string; +} + +export interface V2UKStoredHouseholdEnvelope extends V2UKCreateHouseholdEnvelope { + id: string; + created_at: string; + updated_at: string; +} + +export type V2StoredHouseholdEnvelope = V2USStoredHouseholdEnvelope | V2UKStoredHouseholdEnvelope; + +export type V2HouseholdEnvelope = V2CreateHouseholdEnvelope | V2StoredHouseholdEnvelope; + +export interface V2USHouseholdCalculationPayload extends V2USCreateHouseholdEnvelope { + policy_id?: string; + dynamic_id?: string; +} + +export interface V2UKHouseholdCalculationPayload extends V2UKCreateHouseholdEnvelope { + policy_id?: string; + dynamic_id?: string; +} + +export type V2HouseholdCalculationPayload = + | V2USHouseholdCalculationPayload + | V2UKHouseholdCalculationPayload; + +interface V2BaseHouseholdCalculationResult { + person: TPerson[]; + household: V2HouseholdGroupData[]; +} + +export interface V2USHouseholdCalculationResult extends V2BaseHouseholdCalculationResult { + marital_unit: V2HouseholdGroupData[]; + family: V2HouseholdGroupData[]; + spm_unit: V2HouseholdGroupData[]; + tax_unit: V2HouseholdGroupData[]; +} + +export interface V2UKHouseholdCalculationResult extends V2BaseHouseholdCalculationResult { + benunit: V2HouseholdGroupData[]; +} + +export type V2HouseholdCalculationResult = + | V2USHouseholdCalculationResult + | V2UKHouseholdCalculationResult; diff --git a/app/src/pages/Populations.page.tsx b/app/src/pages/Populations.page.tsx index 6075bd0fb..fc37d0e8b 100644 --- a/app/src/pages/Populations.page.tsx +++ b/app/src/pages/Populations.page.tsx @@ -14,6 +14,7 @@ import { } from '@/hooks/useUserGeographic'; import { useUpdateHouseholdAssociation, useUserHouseholds } from '@/hooks/useUserHousehold'; import { countryIds } from '@/libs/countries'; +import { Household } from '@/models/Household'; import { RootState } from '@/store'; import { UserGeographyPopulation } from '@/types/ingredients/UserPopulation'; import { formatDate } from '@/utils/dateUtils'; @@ -188,9 +189,11 @@ export default function PopulationsPage() { }; // Helper function to get household configuration details - const getHouseholdDetails = (household: any) => { - const peopleCount = Object.keys(household?.household_json?.people || {}).length; - const familiesCount = Object.keys(household?.household_json?.families || {}).length; + const getHouseholdDetails = (household: Household | undefined) => { + const peopleCount = household?.personCount ?? 0; + const families = + (household?.householdData?.families as Record | undefined) ?? {}; + const familiesCount = Object.keys(families).length; return [ { text: `${peopleCount} person${peopleCount !== 1 ? 's' : ''}`, badge: '' }, { text: `${familiesCount} household${familiesCount !== 1 ? 's' : ''}`, badge: '' }, diff --git a/app/src/pages/ReportBuilder.page.tsx b/app/src/pages/ReportBuilder.page.tsx index a411da562..41fa079ac 100644 --- a/app/src/pages/ReportBuilder.page.tsx +++ b/app/src/pages/ReportBuilder.page.tsx @@ -37,7 +37,6 @@ import { import { useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; import { PolicyAdapter } from '@/adapters'; -import { HouseholdAdapter } from '@/adapters/HouseholdAdapter'; import { geographyUsageStore, householdUsageStore } from '@/api/usageTracking'; import HouseholdBuilderForm from '@/components/household/HouseholdBuilderForm'; import { UKOutlineIcon, USOutlineIcon } from '@/components/icons/CountryOutlineIcons'; @@ -81,6 +80,8 @@ import { useUserHouseholds } from '@/hooks/useUserHousehold'; import { useUpdatePolicyAssociation, useUserPolicies } from '@/hooks/useUserPolicy'; import { getBasicInputFields, getDateRange } from '@/libs/metadataUtils'; import { householdAssociationKeys } from '@/libs/queryKeys'; +import { Household as HouseholdModel } from '@/models/Household'; +import type { AppHouseholdInputEnvelope } from '@/models/household/appTypes'; import HistoricalValues from '@/pathways/report/components/policyParameterSelector/HistoricalValues'; import { ModeSelectorButton, @@ -89,7 +90,6 @@ import { } from '@/pathways/report/components/valueSetters'; import { RootState } from '@/store'; import { Geography } from '@/types/ingredients/Geography'; -import { Household } from '@/types/ingredients/Household'; import { Policy } from '@/types/ingredients/Policy'; import { ParameterTreeNode } from '@/types/metadata'; import { ParameterMetadata } from '@/types/metadata/parameterMetadata'; @@ -3534,7 +3534,7 @@ function PopulationBrowseModal({ // Creation mode state const [isCreationMode, setIsCreationMode] = useState(false); const [householdLabel, setHouseholdLabel] = useState(''); - const [householdDraft, setHouseholdDraft] = useState(null); + const [householdDraft, setHouseholdDraft] = useState(null); const [isEditingLabel, setIsEditingLabel] = useState(false); // Get report year (default to current year) @@ -3639,9 +3639,7 @@ function PopulationBrowseModal({ return { id: householdIdStr, label: h.association.label || `Household #${householdIdStr}`, - memberCount: h.household?.household_json?.people - ? Object.keys(h.household.household_json.people).length - : 0, + memberCount: h.household?.personCount ?? 0, sortTimestamp, household: h.household, }; @@ -3703,19 +3701,13 @@ function PopulationBrowseModal({ const householdIdStr = String(householdData.id); householdUsageStore.recordUsage(householdIdStr); - // Convert HouseholdMetadata to Household using the adapter - // If household data isn't available, create a minimal household object with just the ID - let household: Household | null = null; - if (householdData.household) { - household = HouseholdAdapter.fromMetadata(householdData.household); - } else { - // Fallback: create minimal household with ID for selection to work - household = { - id: householdIdStr, - countryId, - householdData: { people: {} }, - }; - } + const household: AppHouseholdInputEnvelope | null = householdData.household + ? householdData.household.toAppInput() + : { + id: householdIdStr, + countryId, + householdData: { people: {} }, + }; const populationState: PopulationStateProps = { geography: null, @@ -3809,7 +3801,10 @@ function PopulationBrowseModal({ return; } - const payload = HouseholdAdapter.toCreationPayload(householdDraft.householdData, countryId); + const payload = HouseholdModel.fromDraft({ + countryId, + householdData: householdDraft.householdData, + }).toV1CreationPayload(); try { const result = await createHousehold(payload); @@ -3819,7 +3814,7 @@ function PopulationBrowseModal({ householdUsageStore.recordUsage(householdId); // Create household with ID set for proper selection highlighting - const createdHousehold: Household = { + const createdHousehold: AppHouseholdInputEnvelope = { ...householdDraft, id: householdId, }; @@ -4710,8 +4705,8 @@ function SimulationCanvas({ (h) => String(h.association.householdId) === householdId ); if (householdData?.household) { - const household = HouseholdAdapter.fromMetadata(householdData.household); - // Use the household.id from the adapter for consistent matching with currentPopulationId + const household = householdData.household; + // Use the household.id for consistent matching with currentPopulationId const resolvedId = household.id || householdId; results.push({ id: resolvedId, @@ -4719,7 +4714,7 @@ function SimulationCanvas({ type: 'household', population: { geography: null, - household, + household: household.toAppInput(), label: householdData.association.label || `Household #${householdId}`, type: 'household', }, diff --git a/app/src/pages/ReportOutput.page.tsx b/app/src/pages/ReportOutput.page.tsx index e99715dd1..0fc2a9f7b 100644 --- a/app/src/pages/ReportOutput.page.tsx +++ b/app/src/pages/ReportOutput.page.tsx @@ -180,7 +180,7 @@ export default function ReportOutputPage({ try { // ShareData already contains user associations - just pass it directly - const newUserReport = await saveSharedReport(shareData, policies ?? []); + const newUserReport = await saveSharedReport(shareData, policies ?? [], households ?? []); // Navigate to owned view (same URL pattern but now in localStorage) nav.push(`/${countryId}/report-output/${newUserReport.id}/${activeTab}`); } catch (error) { diff --git a/app/src/pages/report-output/HouseholdComparativeAnalysisPage.tsx b/app/src/pages/report-output/HouseholdComparativeAnalysisPage.tsx index ed3897f8d..834b04e1a 100644 --- a/app/src/pages/report-output/HouseholdComparativeAnalysisPage.tsx +++ b/app/src/pages/report-output/HouseholdComparativeAnalysisPage.tsx @@ -1,4 +1,4 @@ -import type { Household } from '@/types/ingredients/Household'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import type { Policy } from '@/types/ingredients/Policy'; import type { Simulation } from '@/types/ingredients/Simulation'; import type { UserPolicy } from '@/types/ingredients/UserPolicy'; diff --git a/app/src/pages/report-output/HouseholdOverview.tsx b/app/src/pages/report-output/HouseholdOverview.tsx index 7b402be58..e3d466009 100644 --- a/app/src/pages/report-output/HouseholdOverview.tsx +++ b/app/src/pages/report-output/HouseholdOverview.tsx @@ -5,8 +5,8 @@ import HouseholdBreakdown from '@/components/household/HouseholdBreakdown'; import MetricCard from '@/components/report/MetricCard'; import { Group, Stack, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import { RootState } from '@/store'; -import { Household } from '@/types/ingredients/Household'; import type { Policy } from '@/types/ingredients/Policy'; import type { Simulation } from '@/types/ingredients/Simulation'; import { calculateVariableComparison } from '@/utils/householdComparison'; diff --git a/app/src/pages/report-output/HouseholdReportOutput.tsx b/app/src/pages/report-output/HouseholdReportOutput.tsx index 376a38bc4..66774c9a1 100644 --- a/app/src/pages/report-output/HouseholdReportOutput.tsx +++ b/app/src/pages/report-output/HouseholdReportOutput.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { useSimulationProgressDisplay } from '@/hooks/household'; -import type { Household } from '@/types/ingredients/Household'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import type { Policy } from '@/types/ingredients/Policy'; import type { Report } from '@/types/ingredients/Report'; import type { Simulation } from '@/types/ingredients/Simulation'; diff --git a/app/src/pages/report-output/HouseholdReportViewModel.ts b/app/src/pages/report-output/HouseholdReportViewModel.ts index 1c6aab02a..b69378972 100644 --- a/app/src/pages/report-output/HouseholdReportViewModel.ts +++ b/app/src/pages/report-output/HouseholdReportViewModel.ts @@ -1,6 +1,9 @@ import type { HouseholdReportOrchestrator } from '@/libs/calculations/household/HouseholdReportOrchestrator'; +import type { + AppHouseholdInputEnvelope as Household, + AppHouseholdInputData as HouseholdData, +} from '@/models/household/appTypes'; import type { HouseholdReportConfig, SimulationConfig } from '@/types/calculation/household'; -import type { Household, HouseholdData } from '@/types/ingredients/Household'; import type { Report } from '@/types/ingredients/Report'; import type { Simulation } from '@/types/ingredients/Simulation'; import type { UserPolicy } from '@/types/ingredients/UserPolicy'; diff --git a/app/src/pages/report-output/HouseholdSubPage.tsx b/app/src/pages/report-output/HouseholdSubPage.tsx index 65ec619cf..006adac9d 100644 --- a/app/src/pages/report-output/HouseholdSubPage.tsx +++ b/app/src/pages/report-output/HouseholdSubPage.tsx @@ -1,7 +1,7 @@ import { useSelector } from 'react-redux'; import GroupEntityDisplay from '@/components/report/GroupEntityDisplay'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import { RootState } from '@/store'; -import { Household } from '@/types/ingredients/Household'; import { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; import { extractGroupEntities } from '@/utils/householdIndividuals'; import { householdsAreEqual } from '@/utils/householdTableData'; diff --git a/app/src/pages/report-output/OverviewSubPage.tsx b/app/src/pages/report-output/OverviewSubPage.tsx index c62475329..37e76a625 100644 --- a/app/src/pages/report-output/OverviewSubPage.tsx +++ b/app/src/pages/report-output/OverviewSubPage.tsx @@ -1,5 +1,5 @@ import { SocietyWideReportOutput } from '@/api/societyWideCalculation'; -import { Household } from '@/types/ingredients/Household'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import type { Policy } from '@/types/ingredients/Policy'; import type { Simulation } from '@/types/ingredients/Simulation'; import { ReportOutputType } from '../ReportOutput.page'; diff --git a/app/src/pages/report-output/PopulationSubPage.tsx b/app/src/pages/report-output/PopulationSubPage.tsx index a03c3db09..7f908918d 100644 --- a/app/src/pages/report-output/PopulationSubPage.tsx +++ b/app/src/pages/report-output/PopulationSubPage.tsx @@ -1,5 +1,5 @@ +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import { Geography } from '@/types/ingredients/Geography'; -import { Household } from '@/types/ingredients/Household'; import { Simulation } from '@/types/ingredients/Simulation'; import { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; import GeographySubPage from './GeographySubPage'; diff --git a/app/src/pages/report-output/earnings-variation/BaselineAndReformChart.tsx b/app/src/pages/report-output/earnings-variation/BaselineAndReformChart.tsx index 2b2232635..3f475633d 100644 --- a/app/src/pages/report-output/earnings-variation/BaselineAndReformChart.tsx +++ b/app/src/pages/report-output/earnings-variation/BaselineAndReformChart.tsx @@ -21,8 +21,8 @@ import { MOBILE_BREAKPOINT_QUERY } from '@/hooks/useChartDimensions'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useViewportSize } from '@/hooks/useViewportSize'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import type { RootState } from '@/store'; -import type { Household } from '@/types/ingredients/Household'; import { getClampedChartHeight, getNiceTicks, diff --git a/app/src/pages/report-output/earnings-variation/BaselineOnlyChart.tsx b/app/src/pages/report-output/earnings-variation/BaselineOnlyChart.tsx index f32ba17b1..aadfd86c4 100644 --- a/app/src/pages/report-output/earnings-variation/BaselineOnlyChart.tsx +++ b/app/src/pages/report-output/earnings-variation/BaselineOnlyChart.tsx @@ -18,8 +18,8 @@ import { MOBILE_BREAKPOINT_QUERY } from '@/hooks/useChartDimensions'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useViewportSize } from '@/hooks/useViewportSize'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import type { RootState } from '@/store'; -import type { Household } from '@/types/ingredients/Household'; import { getClampedChartHeight, getNiceTicks, diff --git a/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx b/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx index 8694d78a0..3b671063f 100644 --- a/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx +++ b/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx @@ -17,8 +17,8 @@ import { colors, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useHouseholdVariation } from '@/hooks/useHouseholdVariation'; import { useReportYear } from '@/hooks/useReportYear'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import type { RootState } from '@/store'; -import type { Household } from '@/types/ingredients/Household'; import type { Policy } from '@/types/ingredients/Policy'; import type { Simulation } from '@/types/ingredients/Simulation'; import type { UserPolicy } from '@/types/ingredients/UserPolicy'; diff --git a/app/src/pages/report-output/marginal-tax-rates/MarginalTaxRatesSubPage.tsx b/app/src/pages/report-output/marginal-tax-rates/MarginalTaxRatesSubPage.tsx index ec8852d58..acc8a13a0 100644 --- a/app/src/pages/report-output/marginal-tax-rates/MarginalTaxRatesSubPage.tsx +++ b/app/src/pages/report-output/marginal-tax-rates/MarginalTaxRatesSubPage.tsx @@ -24,8 +24,8 @@ import { useHouseholdVariation } from '@/hooks/useHouseholdVariation'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useReportYear } from '@/hooks/useReportYear'; import { useViewportSize } from '@/hooks/useViewportSize'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import type { RootState } from '@/store'; -import type { Household } from '@/types/ingredients/Household'; import type { Policy } from '@/types/ingredients/Policy'; import type { Simulation } from '@/types/ingredients/Simulation'; import type { UserPolicy } from '@/types/ingredients/UserPolicy'; diff --git a/app/src/pages/report-output/net-income/NetIncomeSubPage.tsx b/app/src/pages/report-output/net-income/NetIncomeSubPage.tsx index 470259f29..5834e362c 100644 --- a/app/src/pages/report-output/net-income/NetIncomeSubPage.tsx +++ b/app/src/pages/report-output/net-income/NetIncomeSubPage.tsx @@ -2,8 +2,8 @@ import { useSelector } from 'react-redux'; import VariableArithmetic from '@/components/household/VariableArithmetic'; import { Stack, Text, Title } from '@/components/ui'; import { useReportYear } from '@/hooks/useReportYear'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import type { RootState } from '@/store'; -import type { Household } from '@/types/ingredients/Household'; import { formatVariableValue, getValueFromHousehold } from '@/utils/householdValues'; interface Props { diff --git a/app/src/pages/reportBuilder/hooks/useSimulationCanvas.ts b/app/src/pages/reportBuilder/hooks/useSimulationCanvas.ts index 5e345be52..5d87c7586 100644 --- a/app/src/pages/reportBuilder/hooks/useSimulationCanvas.ts +++ b/app/src/pages/reportBuilder/hooks/useSimulationCanvas.ts @@ -12,7 +12,6 @@ import { useCallback, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; -import { HouseholdAdapter } from '@/adapters/HouseholdAdapter'; import { geographyUsageStore, householdUsageStore } from '@/api/usageTracking'; import { MOCK_USER_ID } from '@/constants'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; @@ -168,11 +167,11 @@ export function useSimulationCanvas({ const householdData = households?.find( (h) => String(h.association.householdId) === householdId ); - if (!householdData?.household?.household_json) { + if (!householdData?.household) { continue; } - const household = HouseholdAdapter.fromMetadata(householdData.household); + const household = householdData.household; const resolvedId = household.id || householdId; const label = householdData.association.label || `Household #${householdId}`; results.push({ diff --git a/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx b/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx index 2d0b993ab..7fa46edb0 100644 --- a/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx +++ b/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx @@ -16,7 +16,6 @@ import { } from '@tabler/icons-react'; import { useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; -import { HouseholdAdapter } from '@/adapters/HouseholdAdapter'; import { geographyUsageStore, householdUsageStore } from '@/api/usageTracking'; import { UKOutlineIcon, USOutlineIcon } from '@/components/icons/CountryOutlineIcons'; import { Group } from '@/components/ui/Group'; @@ -29,9 +28,10 @@ import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useUserHouseholds } from '@/hooks/useUserHousehold'; import { getBasicInputFields } from '@/libs/metadataUtils'; import { householdAssociationKeys } from '@/libs/queryKeys'; +import { Household as HouseholdModel } from '@/models/Household'; +import type { AppHouseholdInputEnvelope } from '@/models/household/appTypes'; import { RootState } from '@/store'; import { Geography } from '@/types/ingredients/Geography'; -import { Household } from '@/types/ingredients/Household'; import { PopulationStateProps } from '@/types/pathwayState'; import { generateGeographyLabel } from '@/utils/geographyUtils'; import { HouseholdBuilder } from '@/utils/HouseholdBuilder'; @@ -81,7 +81,7 @@ export function PopulationBrowseModal({ // Creation mode state const [isCreationMode, setIsCreationMode] = useState(false); const [householdLabel, setHouseholdLabel] = useState(''); - const [householdDraft, setHouseholdDraft] = useState(null); + const [householdDraft, setHouseholdDraft] = useState(null); // Get report year (default to current year) const reportYear = CURRENT_YEAR.toString(); @@ -194,9 +194,7 @@ export function PopulationBrowseModal({ return { id: householdIdStr, label: h.association.label || `Household #${householdIdStr}`, - memberCount: h.household?.household_json?.people - ? Object.keys(h.household.household_json.people).length - : 0, + memberCount: h.household?.personCount ?? 0, sortTimestamp, household: h.household, }; @@ -256,16 +254,13 @@ export function PopulationBrowseModal({ const householdIdStr = String(householdData.id); householdUsageStore.recordUsage(householdIdStr); - let household: Household | null = null; - if (householdData.household) { - household = HouseholdAdapter.fromMetadata(householdData.household); - } else { - household = { - id: householdIdStr, - countryId, - householdData: { people: {} }, - }; - } + const household: AppHouseholdInputEnvelope | null = householdData.household + ? householdData.household.toAppInput() + : { + id: householdIdStr, + countryId, + householdData: { people: {} }, + }; const populationState: PopulationStateProps = { geography: null, @@ -357,7 +352,10 @@ export function PopulationBrowseModal({ return; } - const payload = HouseholdAdapter.toCreationPayload(householdDraft.householdData, countryId); + const payload = HouseholdModel.fromDraft({ + countryId, + householdData: householdDraft.householdData, + }).toV1CreationPayload(); try { const result = await createHousehold(payload); @@ -365,7 +363,7 @@ export function PopulationBrowseModal({ householdUsageStore.recordUsage(householdId); - const createdHousehold: Household = { + const createdHousehold: AppHouseholdInputEnvelope = { ...householdDraft, id: householdId, }; diff --git a/app/src/pages/reportBuilder/modals/population/HouseholdCreationContent.tsx b/app/src/pages/reportBuilder/modals/population/HouseholdCreationContent.tsx index 08baf0cff..b4e27f745 100644 --- a/app/src/pages/reportBuilder/modals/population/HouseholdCreationContent.tsx +++ b/app/src/pages/reportBuilder/modals/population/HouseholdCreationContent.tsx @@ -4,11 +4,11 @@ import HouseholdBuilderForm from '@/components/household/HouseholdBuilderForm'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Spinner } from '@/components/ui/Spinner'; -import { Household } from '@/types/ingredients/Household'; +import type { AppHouseholdInputEnvelope } from '@/models/household/appTypes'; import { MetadataState } from '@/types/metadata'; interface HouseholdCreationContentProps { - householdDraft: Household | null; + householdDraft: AppHouseholdInputEnvelope | null; metadata: MetadataState; reportYear: string; maritalStatus: 'single' | 'married'; @@ -16,7 +16,7 @@ interface HouseholdCreationContentProps { basicPersonFields: string[]; basicNonPersonFields: string[]; isCreating: boolean; - onChange: (household: Household) => void; + onChange: (household: AppHouseholdInputEnvelope) => void; onMaritalStatusChange: (status: 'single' | 'married') => void; onNumChildrenChange: (count: number) => void; } diff --git a/app/src/pages/reportBuilder/utils/hydrateReportBuilderState.ts b/app/src/pages/reportBuilder/utils/hydrateReportBuilderState.ts index c9eae5eda..fc2957c46 100644 --- a/app/src/pages/reportBuilder/utils/hydrateReportBuilderState.ts +++ b/app/src/pages/reportBuilder/utils/hydrateReportBuilderState.ts @@ -1,5 +1,5 @@ +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import type { Geography } from '@/types/ingredients/Geography'; -import type { Household } from '@/types/ingredients/Household'; import type { Policy } from '@/types/ingredients/Policy'; import type { Report } from '@/types/ingredients/Report'; import type { Simulation } from '@/types/ingredients/Simulation'; diff --git a/app/src/pathways/population/PopulationPathwayWrapper.tsx b/app/src/pathways/population/PopulationPathwayWrapper.tsx index 66f0212b2..427ea5d4f 100644 --- a/app/src/pathways/population/PopulationPathwayWrapper.tsx +++ b/app/src/pathways/population/PopulationPathwayWrapper.tsx @@ -12,8 +12,8 @@ import { useAppNavigate } from '@/contexts/NavigationContext'; import { ReportYearProvider } from '@/contexts/ReportYearContext'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { usePathwayNavigation } from '@/hooks/usePathwayNavigation'; +import type { AppHouseholdInputEnvelope } from '@/models/household/appTypes'; import { RootState } from '@/store'; -import { Household } from '@/types/ingredients/Household'; import { StandalonePopulationViewMode } from '@/types/pathwayModes/PopulationViewMode'; import { PopulationStateProps } from '@/types/pathwayState'; import { createPopulationCallbacks } from '@/utils/pathwayCallbacks'; @@ -60,7 +60,7 @@ export default function PopulationPathwayWrapper({ onComplete }: PopulationPathw StandalonePopulationViewMode.LABEL, // labelMode { // Custom navigation for standalone pathway: exit to households list - onHouseholdComplete: (_householdId: string, _household: Household) => { + onHouseholdComplete: (_householdId: string, _household: AppHouseholdInputEnvelope) => { nav.push(`/${countryId}/households`); onComplete?.(); }, diff --git a/app/src/pathways/report/views/population/HouseholdBuilderView.tsx b/app/src/pathways/report/views/population/HouseholdBuilderView.tsx index e1affd2c6..2be359a06 100644 --- a/app/src/pathways/report/views/population/HouseholdBuilderView.tsx +++ b/app/src/pathways/report/views/population/HouseholdBuilderView.tsx @@ -6,15 +6,15 @@ import { useState } from 'react'; import { useSelector } from 'react-redux'; -import { HouseholdAdapter } from '@/adapters/HouseholdAdapter'; import PathwayView from '@/components/common/PathwayView'; import HouseholdBuilderForm from '@/components/household/HouseholdBuilderForm'; import { Spinner, Stack } from '@/components/ui'; import { useCreateHousehold } from '@/hooks/useCreateHousehold'; import { useReportYear } from '@/hooks/useReportYear'; import { getBasicInputFields } from '@/libs/metadataUtils'; +import { Household as HouseholdModel } from '@/models/Household'; +import type { AppHouseholdInputEnvelope } from '@/models/household/appTypes'; import { RootState } from '@/store'; -import { Household } from '@/types/ingredients/Household'; import { PopulationStateProps } from '@/types/pathwayState'; import { HouseholdBuilder } from '@/utils/HouseholdBuilder'; import { HouseholdValidation } from '@/utils/HouseholdValidation'; @@ -22,7 +22,7 @@ import { HouseholdValidation } from '@/utils/HouseholdValidation'; interface HouseholdBuilderViewProps { population: PopulationStateProps; countryId: string; - onSubmitSuccess: (householdId: string, household: Household) => void; + onSubmitSuccess: (householdId: string, household: AppHouseholdInputEnvelope) => void; onBack?: () => void; } @@ -69,7 +69,7 @@ export default function HouseholdBuilderView({ } // Initialize household with "you" if none exists - const [household, setLocalHousehold] = useState(() => { + const [household, setLocalHousehold] = useState(() => { if (population?.household) { return population.household; } @@ -153,8 +153,10 @@ export default function HouseholdBuilderView({ return; } - // Convert to API format - const payload = HouseholdAdapter.toCreationPayload(household.householdData, countryId); + const payload = HouseholdModel.fromDraft({ + countryId: countryId as AppHouseholdInputEnvelope['countryId'], + householdData: household.householdData, + }).toV1CreationPayload(); try { const result = await createHousehold(payload); diff --git a/app/src/pathways/report/views/population/PopulationExistingView.tsx b/app/src/pathways/report/views/population/PopulationExistingView.tsx index fcb160c99..c82209688 100644 --- a/app/src/pathways/report/views/population/PopulationExistingView.tsx +++ b/app/src/pathways/report/views/population/PopulationExistingView.tsx @@ -6,7 +6,6 @@ import { useState } from 'react'; import { useSelector } from 'react-redux'; -import { HouseholdAdapter } from '@/adapters'; import PathwayView from '@/components/common/PathwayView'; import { MOCK_USER_ID } from '@/constants'; import { @@ -19,9 +18,9 @@ import { UserHouseholdMetadataWithAssociation, useUserHouseholds, } from '@/hooks/useUserHousehold'; +import { Household as HouseholdModel } from '@/models/Household'; import { RootState } from '@/store'; import { Geography } from '@/types/ingredients/Geography'; -import { Household } from '@/types/ingredients/Household'; import { getCountryLabel, getRegionLabel } from '@/utils/geographyUtils'; import { isGeographicAssociationReady, @@ -29,7 +28,7 @@ import { } from '@/utils/validation/ingredientValidation'; interface PopulationExistingViewProps { - onSelectHousehold: (householdId: string, household: Household, label: string) => void; + onSelectHousehold: (householdId: string, household: HouseholdModel, label: string) => void; onSelectGeography: (geographyId: string, geography: Geography, label: string) => void; onBack?: () => void; onCancel?: () => void; @@ -118,23 +117,13 @@ export default function PopulationExistingView({ return; } - // Guard: ensure household data is fully loaded before calling adapter + // Guard: ensure household data is fully loaded before continuing if (!localPopulation.household) { - console.error('[PopulationExistingView] Household metadata is undefined'); + console.error('[PopulationExistingView] Household model is undefined'); return; } - // Handle both API format (household_json) and transformed format (householdData) - // The cache might contain transformed data from useUserSimulations - let householdToSet; - if ('household_json' in localPopulation.household) { - // API format - needs transformation - householdToSet = HouseholdAdapter.fromMetadata(localPopulation.household); - } else { - // Already transformed format from cache - householdToSet = localPopulation.household as any; - } - + const householdToSet = localPopulation.household; const label = localPopulation.association?.label || ''; const householdId = householdToSet.id!; diff --git a/app/src/tests/CLAUDE.md b/app/src/tests/CLAUDE.md index 2846a2292..c667b97fc 100644 --- a/app/src/tests/CLAUDE.md +++ b/app/src/tests/CLAUDE.md @@ -23,7 +23,7 @@ Examples: - `src/components/Button.tsx` → `src/tests/unit/components/Button.test.tsx` - `src/hooks/useAuth.ts` → `src/tests/unit/hooks/useAuth.test.ts` - - `src/adapters/HouseholdAdapter.ts` → `src/tests/unit/adapters/HouseholdAdapter.test.ts` + - `src/models/Household.ts` → `src/tests/unit/models/Household.test.ts` - API integration test → `src/tests/integration/api/PolicyEngine.test.ts` 3. **Test naming**: Use Given-When-Then pattern for clear, descriptive test names diff --git a/app/src/tests/fixtures/api/householdCalculationMocks.ts b/app/src/tests/fixtures/api/householdCalculationMocks.ts index 6be059f54..f3d43a573 100644 --- a/app/src/tests/fixtures/api/householdCalculationMocks.ts +++ b/app/src/tests/fixtures/api/householdCalculationMocks.ts @@ -1,7 +1,7 @@ import { vi } from 'vitest'; import { HouseholdCalculationResponse } from '@/api/householdCalculation'; import { CURRENT_YEAR } from '@/constants'; -import { Household } from '@/types/ingredients/Household'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; // Test IDs and constants export const TEST_COUNTRIES = { @@ -73,7 +73,7 @@ export const mockHouseholdResult: Household = { family_size: { [CURRENT_YEAR]: 2 }, }, }, - tax_units: { + taxUnits: { tax_unit1: { members: ['person1', 'person2'], adjusted_gross_income: { [CURRENT_YEAR]: 102000 }, @@ -134,7 +134,7 @@ export const mockLargeHouseholdResult: Household = { family_size: { [CURRENT_YEAR]: 5 }, }, }, - tax_units: { + taxUnits: { tax_unit1: { members: ['person1', 'person2', 'person3', 'person4', 'person5'], adjusted_gross_income: { [CURRENT_YEAR]: 140000 }, diff --git a/app/src/tests/fixtures/api/householdMocks.ts b/app/src/tests/fixtures/api/householdMocks.ts index 899754447..e7d03ccab 100644 --- a/app/src/tests/fixtures/api/householdMocks.ts +++ b/app/src/tests/fixtures/api/householdMocks.ts @@ -1,6 +1,6 @@ import { vi } from 'vitest'; import { CURRENT_YEAR } from '@/constants'; -import { HouseholdMetadata } from '@/types/metadata/householdMetadata'; +import type { V1HouseholdMetadataEnvelope } from '@/models/household/v1Types'; import { HouseholdCreationPayload } from '@/types/payloads'; // Test household IDs - descriptive names for clarity @@ -31,7 +31,7 @@ export const ERROR_MESSAGES = { FAILED_TO_FETCH: 'Failed to fetch', } as const; -export const mockHouseholdMetadata: HouseholdMetadata = { +export const mockHouseholdMetadata: V1HouseholdMetadataEnvelope = { id: '12345', country_id: 'us', household_json: { diff --git a/app/src/tests/fixtures/api/v2/shared.ts b/app/src/tests/fixtures/api/v2/shared.ts index a92298933..2e4f2168e 100644 --- a/app/src/tests/fixtures/api/v2/shared.ts +++ b/app/src/tests/fixtures/api/v2/shared.ts @@ -5,6 +5,12 @@ * Individual test files import what they need. */ import { vi } from 'vitest'; +import type { + V2UKCreateHouseholdEnvelope, + V2UKStoredHouseholdEnvelope, + V2USCreateHouseholdEnvelope, + V2USStoredHouseholdEnvelope, +} from '@/models/household/v2Types'; // ============================================================================ // Constants @@ -108,36 +114,99 @@ export function createMockModelVersion( // Mock response factories — Households // ============================================================================ -export function createMockHouseholdV2Response(overrides?: Partial<{ id: string }>) { +export function createMockHouseholdV2Response( + overrides?: Partial +): V2USStoredHouseholdEnvelope { return { id: overrides?.id ?? TEST_IDS.HOUSEHOLD_ID, - country_id: TEST_COUNTRY_ID, + country_id: 'us', year: 2026, label: 'Test household', - people: [{ age: 30, employment_income: 50000 }], - tax_unit: { members: ['person1'] }, - family: null, - spm_unit: null, - marital_unit: null, - household: null, - benunit: null, + people: [ + { + name: 'adult', + person_id: 0, + person_tax_unit_id: 0, + age: 30, + employment_income: 50000, + }, + ], + tax_unit: [{ tax_unit_id: 0 }], + family: [], + spm_unit: [], + marital_unit: [], + household: [], created_at: TEST_TIMESTAMP, updated_at: TEST_TIMESTAMP, + ...overrides, }; } -export function createMockV2HouseholdShape() { +export function createMockUkHouseholdV2Response( + overrides?: Partial +): V2UKStoredHouseholdEnvelope { return { - country_id: TEST_COUNTRY_ID as 'us', + id: overrides?.id ?? TEST_IDS.HOUSEHOLD_ID, + country_id: 'uk', year: 2026, label: 'Test household', - people: [{ age: 30, employment_income: 50000 }], - tax_unit: { members: ['person1'] }, - family: null, - spm_unit: null, - marital_unit: null, - household: null, - benunit: null, + people: [ + { + name: 'adult', + person_id: 0, + person_household_id: 0, + person_benunit_id: 0, + age: 30, + employment_income: 50000, + }, + ], + household: [{ household_id: 0 }], + benunit: [{ benunit_id: 0 }], + created_at: TEST_TIMESTAMP, + updated_at: TEST_TIMESTAMP, + ...overrides, + }; +} + +export function createMockV2CreateHouseholdEnvelope(): V2USCreateHouseholdEnvelope { + return { + country_id: 'us', + year: 2026, + label: 'Test household', + people: [ + { + name: 'adult', + person_id: 0, + person_tax_unit_id: 0, + age: 30, + employment_income: 50000, + }, + ], + tax_unit: [{ tax_unit_id: 0 }], + family: [], + spm_unit: [], + marital_unit: [], + household: [], + }; +} + +export function createMockUkV2CreateHouseholdEnvelope(): V2UKCreateHouseholdEnvelope { + return { + country_id: 'uk', + year: 2026, + label: 'Test household', + people: [ + { + name: 'adult', + person_id: 0, + person_household_id: 0, + person_benunit_id: 0, + age: 30, + employment_income: 50000, + }, + ], + household: [{ household_id: 0 }], + benunit: [{ benunit_id: 0 }], }; } diff --git a/app/src/tests/fixtures/hooks/hooksMocks.ts b/app/src/tests/fixtures/hooks/hooksMocks.ts index 66c622208..4331e401b 100644 --- a/app/src/tests/fixtures/hooks/hooksMocks.ts +++ b/app/src/tests/fixtures/hooks/hooksMocks.ts @@ -1,11 +1,11 @@ import { QueryClient } from '@tanstack/react-query'; import { vi } from 'vitest'; import { CURRENT_YEAR } from '@/constants'; +import type { V1HouseholdMetadataEnvelope } from '@/models/household/v1Types'; import { UserGeographyPopulation, UserHouseholdPopulation, } from '@/types/ingredients/UserPopulation'; -import { HouseholdMetadata } from '@/types/metadata/householdMetadata'; import { HouseholdCreationPayload } from '@/types/payloads'; // ============= TEST CONSTANTS ============= @@ -94,7 +94,7 @@ export const TEST_VALUES = { // ============= MOCK DATA OBJECTS ============= -export const mockHouseholdMetadata: HouseholdMetadata = { +export const mockHouseholdMetadata: V1HouseholdMetadataEnvelope = { id: TEST_IDS.HOUSEHOLD_ID.split('-')[1], country_id: GEO_CONSTANTS.COUNTRY_US, household_json: { @@ -172,6 +172,9 @@ export const mockHouseholdCreationPayload: HouseholdCreationPayload = { person1: { age: { [CURRENT_YEAR]: TEST_VALUES.AGE }, }, + person2: { + age: { [CURRENT_YEAR]: TEST_VALUES.AGE - 2 }, + }, }, tax_units: { unit1: { diff --git a/app/src/tests/fixtures/hooks/reportHooksMocks.ts b/app/src/tests/fixtures/hooks/reportHooksMocks.ts index 1293dffbf..baf2169b7 100644 --- a/app/src/tests/fixtures/hooks/reportHooksMocks.ts +++ b/app/src/tests/fixtures/hooks/reportHooksMocks.ts @@ -1,7 +1,7 @@ import { QueryClient } from '@tanstack/react-query'; import { vi } from 'vitest'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import { Geography } from '@/types/ingredients/Geography'; -import { Household } from '@/types/ingredients/Household'; import { Simulation } from '@/types/ingredients/Simulation'; import { UserReport } from '@/types/ingredients/UserReport'; @@ -56,10 +56,10 @@ export const mockHousehold: Household = { householdData: { people: {}, families: {}, - tax_units: {}, - spm_units: {}, + taxUnits: {}, + spmUnits: {}, households: {}, - marital_units: {}, + maritalUnits: {}, }, }; diff --git a/app/src/tests/fixtures/hooks/useSaveSharedReportMocks.ts b/app/src/tests/fixtures/hooks/useSaveSharedReportMocks.ts index 9613022d0..2f1831044 100644 --- a/app/src/tests/fixtures/hooks/useSaveSharedReportMocks.ts +++ b/app/src/tests/fixtures/hooks/useSaveSharedReportMocks.ts @@ -4,6 +4,9 @@ import { vi } from 'vitest'; import { ReportIngredientsInput } from '@/hooks/utils/useFetchReportIngredients'; +import { Household } from '@/models/Household'; +import type { AppHouseholdInputEnvelope } from '@/models/household/appTypes'; +import type { V1HouseholdMetadataEnvelope } from '@/models/household/v1Types'; import { Policy } from '@/types/ingredients/Policy'; // ============================================================================ @@ -109,6 +112,17 @@ export const MOCK_SAVED_USER_POLICY = { isCreated: true, }; +export const MOCK_SAVED_USER_HOUSEHOLD = { + id: 'suh-household-save-1', + userId: 'anonymous', + householdId: TEST_IDS.HOUSEHOLD, + countryId: TEST_COUNTRIES.UK, + label: 'My Household', + createdAt: '2026-04-08T12:00:00Z', + isCreated: true, + type: 'household' as const, +}; + export const MOCK_EXISTING_USER_REPORT = { ...MOCK_SAVED_USER_REPORT, label: 'Already Saved Report', @@ -128,6 +142,61 @@ export const MOCK_POLICIES: Policy[] = [ }, ]; +export const MOCK_HOUSEHOLDS: AppHouseholdInputEnvelope[] = [ + { + id: TEST_IDS.HOUSEHOLD, + countryId: TEST_COUNTRIES.UK, + householdData: { + people: { + you: { + age: { '2026': 40 }, + employment_income: { '2026': 30000 }, + }, + }, + households: { + household1: { + members: ['you'], + }, + }, + benunits: { + benunit1: { + members: ['you'], + }, + }, + }, + }, +]; + +const MOCK_SHARED_V1_HOUSEHOLD_METADATA: V1HouseholdMetadataEnvelope = { + id: TEST_IDS.HOUSEHOLD, + country_id: TEST_COUNTRIES.UK, + label: 'Fetched Shared Household', + api_version: 'v1', + household_hash: 'shared-household-hash', + household_json: { + people: { + you: { + age: { '2026': 40 }, + employment_income: { '2026': 30000 }, + }, + }, + households: { + household1: { + members: ['you'], + }, + }, + benunits: { + benunit1: { + members: ['you'], + }, + }, + }, +}; + +export const MOCK_SHARED_V1_HOUSEHOLD_MODEL = Household.fromV1Metadata( + MOCK_SHARED_V1_HOUSEHOLD_METADATA +); + // ============================================================================ // Mock Hooks Factory // ============================================================================ diff --git a/app/src/tests/fixtures/hooks/useUserHouseholdMocks.ts b/app/src/tests/fixtures/hooks/useUserHouseholdMocks.ts index cc18a6b04..53cb774f7 100644 --- a/app/src/tests/fixtures/hooks/useUserHouseholdMocks.ts +++ b/app/src/tests/fixtures/hooks/useUserHouseholdMocks.ts @@ -1,10 +1,11 @@ // Fixtures for useUserHouseholds and useUserGeographics hooks +import { Household as HouseholdModel } from '@/models/Household'; +import type { V1HouseholdMetadataEnvelope } from '@/models/household/v1Types'; import { Geography } from '@/types/ingredients/Geography'; import { UserGeographyPopulation, UserHouseholdPopulation, } from '@/types/ingredients/UserPopulation'; -import { HouseholdMetadata } from '@/types/metadata/householdMetadata'; // Test household IDs export const TEST_HOUSEHOLD_ID_1 = 'household-123'; @@ -19,7 +20,7 @@ export const TEST_HOUSEHOLD_LABEL = 'Test Household Population'; export const TEST_GEOGRAPHY_LABEL = 'Test Geography Population'; // Mock household metadata (API format) -export const mockApiHouseholdMetadata1: HouseholdMetadata = { +export const mockApiHouseholdMetadata1: V1HouseholdMetadataEnvelope = { id: TEST_HOUSEHOLD_ID_1, country_id: 'us', label: TEST_HOUSEHOLD_LABEL, @@ -62,13 +63,13 @@ export const mockHouseholdAssociation1: UserHouseholdPopulation = { // Combined metadata with association (returned by useUserHouseholds) export const mockHouseholdMetadata = { association: mockHouseholdAssociation1, - household: mockApiHouseholdMetadata1, + household: HouseholdModel.fromV1Metadata(mockApiHouseholdMetadata1), isLoading: false, error: null, isError: false, }; -export const mockApiHouseholdMetadata2: HouseholdMetadata = { +export const mockApiHouseholdMetadata2: V1HouseholdMetadataEnvelope = { id: TEST_HOUSEHOLD_ID_2, country_id: 'us', label: 'Second Household', @@ -109,7 +110,7 @@ export const mockHouseholdAssociation2: UserHouseholdPopulation = { export const mockHouseholdMetadata2 = { association: mockHouseholdAssociation2, - household: mockApiHouseholdMetadata2, + household: HouseholdModel.fromV1Metadata(mockApiHouseholdMetadata2), isLoading: false, error: null, isError: false, diff --git a/app/src/tests/fixtures/hooks/useUserReportsMocks.ts b/app/src/tests/fixtures/hooks/useUserReportsMocks.ts index 870c73c8c..0dbf9aa82 100644 --- a/app/src/tests/fixtures/hooks/useUserReportsMocks.ts +++ b/app/src/tests/fixtures/hooks/useUserReportsMocks.ts @@ -1,11 +1,11 @@ -import { Household } from '@/types/ingredients/Household'; +import { Household as HouseholdModel } from '@/models/Household'; +import type { V1HouseholdMetadataEnvelope } from '@/models/household/v1Types'; import { Policy } from '@/types/ingredients/Policy'; import { Simulation } from '@/types/ingredients/Simulation'; import { UserPolicy } from '@/types/ingredients/UserPolicy'; import { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; import { UserSimulation } from '@/types/ingredients/UserSimulation'; import { MetadataState } from '@/types/metadata'; -import { HouseholdMetadata } from '@/types/metadata/householdMetadata'; import { PolicyMetadata } from '@/types/metadata/policyMetadata'; import { SimulationMetadata } from '@/types/metadata/simulationMetadata'; import { US_REGION_TYPES } from '@/types/regionTypes'; @@ -63,10 +63,11 @@ export const mockPolicy2: Policy = { }; // Mock Household entity -export const mockHousehold1: Household = { +export const mockHousehold1 = HouseholdModel.fromV1Metadata({ id: TEST_HOUSEHOLD_ID, - countryId: TEST_COUNTRIES.US, - householdData: { + country_id: TEST_COUNTRIES.US, + api_version: 'v1', + household_json: { people: {}, families: {}, tax_units: {}, @@ -74,7 +75,9 @@ export const mockHousehold1: Household = { households: {}, marital_units: {}, }, -}; + household_hash: 'hash-household-123', + label: 'Test Household', +}); // Mock User Associations export const mockUserSimulations: UserSimulation[] = [ @@ -164,7 +167,7 @@ export const mockPolicyMetadata2: PolicyMetadata = { label: 'Test Policy 2', }; -export const mockHouseholdMetadata: HouseholdMetadata = { +export const mockHouseholdMetadata: V1HouseholdMetadataEnvelope = { id: TEST_HOUSEHOLD_ID, country_id: TEST_COUNTRIES.US, api_version: 'v1', diff --git a/app/src/tests/fixtures/libs/calculations/orchestrationFixtures.ts b/app/src/tests/fixtures/libs/calculations/orchestrationFixtures.ts index 8a1605096..e2a3fcb81 100644 --- a/app/src/tests/fixtures/libs/calculations/orchestrationFixtures.ts +++ b/app/src/tests/fixtures/libs/calculations/orchestrationFixtures.ts @@ -1,8 +1,8 @@ import type { QueryClient } from '@tanstack/react-query'; import { vi } from 'vitest'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import type { CalcStartConfig } from '@/types/calculation'; import type { Geography } from '@/types/ingredients/Geography'; -import type { Household } from '@/types/ingredients/Household'; import type { Simulation } from '@/types/ingredients/Simulation'; /** diff --git a/app/src/tests/fixtures/libs/calculations/strategyFixtures.ts b/app/src/tests/fixtures/libs/calculations/strategyFixtures.ts index fa220816b..37570e453 100644 --- a/app/src/tests/fixtures/libs/calculations/strategyFixtures.ts +++ b/app/src/tests/fixtures/libs/calculations/strategyFixtures.ts @@ -1,10 +1,10 @@ import { vi } from 'vitest'; import { SocietyWideCalculationResponse } from '@/api/societyWideCalculation'; +import type { AppHouseholdInputData as HouseholdData } from '@/models/household/appTypes'; import { mockHouseholdResult, mockSocietyWideResult, } from '@/tests/fixtures/types/calculationFixtures'; -import { HouseholdData } from '@/types/ingredients/Household'; /** * Test constants for strategy timing and progress diff --git a/app/src/tests/fixtures/models/shared.ts b/app/src/tests/fixtures/models/shared.ts index 21181bf08..2d8fe9a14 100644 --- a/app/src/tests/fixtures/models/shared.ts +++ b/app/src/tests/fixtures/models/shared.ts @@ -1,6 +1,7 @@ import type { V2PolicyResponse, V2PolicyResponseParameterValue } from '@/api/policy'; -import type { HouseholdV2Response } from '@/api/v2/households'; import type { CountryId } from '@/libs/countries'; +import type { AppHouseholdInputData } from '@/models/household/appTypes'; +import type { V2USStoredHouseholdEnvelope } from '@/models/household/v2Types'; // ============================================================================ // Test constants @@ -93,7 +94,7 @@ export interface HouseholdDataShape { countryId: CountryId; year: number | null; label: string | null; - data: Record; + data: AppHouseholdInputData; } export const createMockHouseholdData = ( @@ -105,13 +106,13 @@ export const createMockHouseholdData = ( label: TEST_HOUSEHOLD_LABEL, data: { people: { - adult: { age: { 2026: 35 }, income: { 2026: 50000 } }, + adult: { age: { 2026: 35 }, employment_income: { 2026: 50000 } }, child: { age: { 2026: 8 } }, }, - tax_unit: { unit_1: { members: ['adult', 'child'] } }, - family: { family_1: { members: ['adult', 'child'] } }, - spm_unit: { spm_1: { members: ['adult', 'child'] } }, - household: { household_1: { members: ['adult', 'child'] } }, + taxUnits: { taxUnit1: { members: ['adult', 'child'] } }, + families: { family1: { members: ['adult', 'child'] } }, + spmUnits: { spmUnit1: { members: ['adult', 'child'] } }, + households: { household1: { members: ['adult', 'child'] } }, }, ...overrides, }); @@ -120,7 +121,7 @@ export const createMockEmptyHouseholdData = ( overrides?: Partial ): HouseholdDataShape => createMockHouseholdData({ - data: {}, + data: { people: {} }, ...overrides, }); @@ -179,38 +180,54 @@ export const createMockV2PolicyResponseNoParams = ( // ============================================================================ export const createMockHouseholdV2Response = ( - overrides?: Partial -): HouseholdV2Response => ({ + overrides?: Partial +): V2USStoredHouseholdEnvelope => ({ id: TEST_HOUSEHOLD_IDS.HOUSEHOLD_A, - country_id: TEST_COUNTRY_ID, + country_id: 'us', year: 2026, label: 'My v2 household', people: [ - { name: 'adult', age: 35, income: 50000 }, - { name: 'child', age: 8 }, + { + name: 'adult', + person_id: 0, + person_household_id: 0, + person_tax_unit_id: 0, + person_family_id: 0, + person_spm_unit_id: 0, + person_marital_unit_id: 0, + age: 35, + employment_income: 50000, + }, + { + name: 'child', + person_id: 1, + person_household_id: 0, + person_tax_unit_id: 0, + person_family_id: 0, + person_spm_unit_id: 0, + age: 8, + }, ], - tax_unit: { unit_1: { members: ['adult', 'child'] } }, - family: { family_1: { members: ['adult', 'child'] } }, - spm_unit: { spm_1: { members: ['adult', 'child'] } }, - marital_unit: { marital_1: { members: ['adult'] } }, - household: { household_1: { members: ['adult', 'child'] } }, - benunit: null, + tax_unit: [{ tax_unit_id: 0 }], + family: [{ family_id: 0 }], + spm_unit: [{ spm_unit_id: 0 }], + marital_unit: [{ marital_unit_id: 0 }], + household: [{ household_id: 0 }], created_at: TEST_TIMESTAMP, updated_at: TEST_UPDATED_TIMESTAMP, ...overrides, }); export const createMockHouseholdV2ResponseMinimal = ( - overrides?: Partial -): HouseholdV2Response => + overrides?: Partial +): V2USStoredHouseholdEnvelope => createMockHouseholdV2Response({ people: [{ name: 'single_adult', age: 30 }], - tax_unit: null, - family: null, - spm_unit: null, - marital_unit: null, - household: null, - benunit: null, + tax_unit: [], + family: [], + spm_unit: [], + marital_unit: [], + household: [], ...overrides, }); diff --git a/app/src/tests/fixtures/adapters/HouseholdAdapterMocks.ts b/app/src/tests/fixtures/models/v1HouseholdMocks.ts similarity index 89% rename from app/src/tests/fixtures/adapters/HouseholdAdapterMocks.ts rename to app/src/tests/fixtures/models/v1HouseholdMocks.ts index 2b7873336..f8f3e6fbb 100644 --- a/app/src/tests/fixtures/adapters/HouseholdAdapterMocks.ts +++ b/app/src/tests/fixtures/models/v1HouseholdMocks.ts @@ -1,6 +1,6 @@ import { CURRENT_YEAR } from '@/constants'; -import { HouseholdData } from '@/types/ingredients/Household'; -import { HouseholdMetadata } from '@/types/metadata/householdMetadata'; +import type { AppHouseholdInputData as HouseholdData } from '@/models/household/appTypes'; +import type { V1HouseholdMetadataEnvelope } from '@/models/household/v1Types'; export const mockEntityMetadata = { person: { @@ -30,7 +30,7 @@ export const mockEntityMetadata = { }, }; -export const mockHouseholdMetadata: HouseholdMetadata = { +export const mockHouseholdMetadata: V1HouseholdMetadataEnvelope = { id: '12345', country_id: 'us', household_json: { @@ -74,7 +74,7 @@ export const mockHouseholdMetadata: HouseholdMetadata = { household_hash: '', }; -export const mockHouseholdMetadataWithUnknownEntity: HouseholdMetadata = { +export const mockHouseholdMetadataWithUnknownEntity: V1HouseholdMetadataEnvelope = { id: '67890', country_id: 'uk', household_json: { diff --git a/app/src/tests/fixtures/pages/populationsMocks.ts b/app/src/tests/fixtures/pages/populationsMocks.ts index c91be68ae..f65040138 100644 --- a/app/src/tests/fixtures/pages/populationsMocks.ts +++ b/app/src/tests/fixtures/pages/populationsMocks.ts @@ -1,10 +1,19 @@ import { vi } from 'vitest'; import { CURRENT_YEAR } from '@/constants'; +import { Household as HouseholdModel } from '@/models/Household'; +import type { V1HouseholdMetadataEnvelope } from '@/models/household/v1Types'; import { UserGeographyPopulation, UserHouseholdPopulation, } from '@/types/ingredients/UserPopulation'; -import { HouseholdMetadata } from '@/types/metadata/householdMetadata'; + +function cloneValue(value: T): T { + if (typeof structuredClone === 'function') { + return structuredClone(value); + } + + return JSON.parse(JSON.stringify(value)) as T; +} // ============= TEST CONSTANTS ============= @@ -109,7 +118,7 @@ export const POPULATION_ERRORS = { // ============= MOCK DATA OBJECTS ============= // Mock household metadata -export const mockHouseholdMetadata1: HouseholdMetadata = { +export const mockHouseholdMetadata1: V1HouseholdMetadataEnvelope = { id: POPULATION_TEST_IDS.HOUSEHOLD_ID_1.split('-')[1], country_id: POPULATION_GEO.COUNTRY_US, household_json: { @@ -140,12 +149,12 @@ export const mockHouseholdMetadata1: HouseholdMetadata = { }, households: { household1: { - members: ['person1', 'person2'], + members: ['person1'], }, }, marital_units: { unit1: { - members: ['person1', 'person2'], + members: ['person1'], }, }, }, @@ -153,7 +162,7 @@ export const mockHouseholdMetadata1: HouseholdMetadata = { household_hash: '', }; -export const mockHouseholdMetadata2: HouseholdMetadata = { +export const mockHouseholdMetadata2: V1HouseholdMetadataEnvelope = { id: POPULATION_TEST_IDS.HOUSEHOLD_ID_2.split('-')[1], country_id: POPULATION_GEO.COUNTRY_US, household_json: { @@ -175,12 +184,12 @@ export const mockHouseholdMetadata2: HouseholdMetadata = { }, households: { household1: { - members: ['person1', 'person2'], + members: ['person1'], }, }, marital_units: { unit1: { - members: ['person1', 'person2'], + members: ['person1'], }, }, }, @@ -213,6 +222,10 @@ export const mockHouseholdAssociation2: UserHouseholdPopulation = { isCreated: true, }; +export const mockHousehold1 = HouseholdModel.fromV1Metadata(mockHouseholdMetadata1); + +export const mockHousehold2 = HouseholdModel.fromV1Metadata(mockHouseholdMetadata2); + // Mock geographic associations export const mockGeographicAssociation1: UserGeographyPopulation = { type: 'geography', @@ -236,27 +249,49 @@ export const mockGeographicAssociation2: UserGeographyPopulation = { createdAt: POPULATION_TEST_IDS.TIMESTAMP_2, }; -// Combined mock data for useUserHouseholds hook -export const mockUserHouseholdsData = [ +export const createMockHousehold1 = (): HouseholdModel => + HouseholdModel.fromAppInput(mockHousehold1.toAppInput()); + +export const createMockHousehold2 = (): HouseholdModel => + HouseholdModel.fromAppInput(mockHousehold2.toAppInput()); + +export const createMockHouseholdAssociation1 = (): UserHouseholdPopulation => + cloneValue(mockHouseholdAssociation1); + +export const createMockHouseholdAssociation2 = (): UserHouseholdPopulation => + cloneValue(mockHouseholdAssociation2); + +export const createMockGeographicAssociation1 = (): UserGeographyPopulation => + cloneValue(mockGeographicAssociation1); + +export const createMockGeographicAssociation2 = (): UserGeographyPopulation => + cloneValue(mockGeographicAssociation2); + +export const createMockUserHouseholdsData = () => [ { - association: mockHouseholdAssociation1, - household: mockHouseholdMetadata1, + association: createMockHouseholdAssociation1(), + household: createMockHousehold1(), isLoading: false, error: null, }, { - association: mockHouseholdAssociation2, - household: mockHouseholdMetadata2, + association: createMockHouseholdAssociation2(), + household: createMockHousehold2(), isLoading: false, error: null, }, ]; -export const mockGeographicAssociationsData = [ - mockGeographicAssociation1, - mockGeographicAssociation2, +export const createMockGeographicAssociationsData = () => [ + createMockGeographicAssociation1(), + createMockGeographicAssociation2(), ]; +// Combined mock data for useUserHouseholds hook +export const mockUserHouseholdsData = createMockUserHouseholdsData(); + +export const mockGeographicAssociationsData = createMockGeographicAssociationsData(); + // ============= MOCK FUNCTIONS ============= // Redux dispatch mock @@ -264,14 +299,14 @@ export const mockDispatch = vi.fn(); // Hook mocks export const mockUseUserHouseholds = vi.fn(() => ({ - data: mockUserHouseholdsData, + data: createMockUserHouseholdsData(), isLoading: false, isError: false, error: null, })); export const mockUseGeographicAssociationsByUser = vi.fn(() => ({ - data: mockGeographicAssociationsData, + data: createMockGeographicAssociationsData(), isLoading: false, isError: false, error: null, @@ -299,13 +334,13 @@ export const setupMockConsole = () => { // Helper to create loading states export const createLoadingState = (householdLoading = true, geographicLoading = false) => ({ household: { - data: householdLoading ? undefined : mockUserHouseholdsData, + data: householdLoading ? undefined : createMockUserHouseholdsData(), isLoading: householdLoading, isError: false, error: null, }, geographic: { - data: geographicLoading ? undefined : mockGeographicAssociationsData, + data: geographicLoading ? undefined : createMockGeographicAssociationsData(), isLoading: geographicLoading, isError: false, error: null, @@ -315,13 +350,13 @@ export const createLoadingState = (householdLoading = true, geographicLoading = // Helper to create error states export const createErrorState = (householdError = false, geographicError = false) => ({ household: { - data: householdError ? undefined : mockUserHouseholdsData, + data: householdError ? undefined : createMockUserHouseholdsData(), isLoading: false, isError: householdError, error: householdError ? new Error(POPULATION_ERRORS.HOUSEHOLD_FETCH_FAILED) : null, }, geographic: { - data: geographicError ? undefined : mockGeographicAssociationsData, + data: geographicError ? undefined : createMockGeographicAssociationsData(), isLoading: false, isError: geographicError, error: geographicError ? new Error(POPULATION_ERRORS.GEOGRAPHIC_FETCH_FAILED) : null, diff --git a/app/src/tests/fixtures/pages/report-output/PopulationSubPage.ts b/app/src/tests/fixtures/pages/report-output/PopulationSubPage.ts index 899105856..9c2535b9f 100644 --- a/app/src/tests/fixtures/pages/report-output/PopulationSubPage.ts +++ b/app/src/tests/fixtures/pages/report-output/PopulationSubPage.ts @@ -1,5 +1,5 @@ +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import { Geography } from '@/types/ingredients/Geography'; -import { Household } from '@/types/ingredients/Household'; import { Simulation } from '@/types/ingredients/Simulation'; import { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; diff --git a/app/src/tests/fixtures/types/calculationFixtures.ts b/app/src/tests/fixtures/types/calculationFixtures.ts index cc3035156..1fac22ace 100644 --- a/app/src/tests/fixtures/types/calculationFixtures.ts +++ b/app/src/tests/fixtures/types/calculationFixtures.ts @@ -1,6 +1,6 @@ import { SocietyWideReportOutput } from '@/api/societyWideCalculation'; +import type { AppHouseholdInputData as HouseholdData } from '@/models/household/appTypes'; import { CalcError, CalcMetadata, CalcParams, CalcStatus } from '@/types/calculation'; -import { HouseholdData } from '@/types/ingredients/Household'; /** * Mock CalcError for testing diff --git a/app/src/tests/fixtures/utils/householdBuilderMocks.ts b/app/src/tests/fixtures/utils/householdBuilderMocks.ts index 88da77955..c9c4aac6a 100644 --- a/app/src/tests/fixtures/utils/householdBuilderMocks.ts +++ b/app/src/tests/fixtures/utils/householdBuilderMocks.ts @@ -1,10 +1,10 @@ import { CURRENT_YEAR } from '@/constants'; -import { - Household, - HouseholdData, - HouseholdGroupEntity, - HouseholdPerson, -} from '@/types/ingredients/Household'; +import type { + AppHouseholdInputEnvelope as Household, + AppHouseholdInputData as HouseholdData, + AppHouseholdInputPerson as HouseholdPerson, +} from '@/models/household/appTypes'; +import { getHouseholdGroupCollection } from '@/utils/householdDataAccess'; // ============= TEST CONSTANTS ============= @@ -275,7 +275,10 @@ export const verifyPersonExists = ( expect(person).toBeDefined(); if (expectedAge !== undefined) { - const ageValues = Object.values(person.age); + const ageValues = + person.age && typeof person.age === 'object' && !Array.isArray(person.age) + ? Object.values(person.age) + : []; expect(ageValues[0]).toBe(expectedAge); } }; @@ -287,24 +290,31 @@ export const verifyPersonInGroup = ( entityName: string, groupKey: string ): void => { - const entities = household.householdData[entityName] as Record; + const entities = getHouseholdGroupCollection(household.householdData, entityName); expect(entities).toBeDefined(); + if (!entities) { + return; + } expect(entities[groupKey]).toBeDefined(); expect(entities[groupKey].members).toContain(personName); }; // Helper to verify person not in any group export const verifyPersonNotInAnyGroup = (household: Household, personName: string): void => { - Object.keys(household.householdData).forEach((entityName) => { - if (entityName === 'people') { + [ + ENTITY_NAMES.HOUSEHOLDS, + ENTITY_NAMES.FAMILIES, + ENTITY_NAMES.TAX_UNITS, + ENTITY_NAMES.SPM_UNITS, + ENTITY_NAMES.MARITAL_UNITS, + ENTITY_NAMES.BEN_UNITS, + ].forEach((entityName) => { + const entities = getHouseholdGroupCollection(household.householdData, entityName); + if (!entities) { return; } - - const entities = household.householdData[entityName] as Record; Object.values(entities).forEach((group) => { - if (group.members) { - expect(group.members).not.toContain(personName); - } + expect(group.members).not.toContain(personName); }); }); }; @@ -331,17 +341,17 @@ export const countGroupMembers = ( entityName: string, groupKey: string ): number => { - const entities = household.householdData[entityName] as Record; + const entities = getHouseholdGroupCollection(household.householdData, entityName); if (!entities || !entities[groupKey]) { return 0; } - return entities[groupKey].members?.length || 0; + return entities[groupKey].members.length; }; // Helper to get all group keys for an entity export const getGroupKeys = (household: Household, entityName: string): string[] => { - const entities = household.householdData[entityName]; - if (!entities || typeof entities !== 'object') { + const entities = getHouseholdGroupCollection(household.householdData, entityName); + if (!entities) { return []; } return Object.keys(entities); diff --git a/app/src/tests/fixtures/utils/householdComparisonMocks.ts b/app/src/tests/fixtures/utils/householdComparisonMocks.ts index 8ea18afae..bc6473e60 100644 --- a/app/src/tests/fixtures/utils/householdComparisonMocks.ts +++ b/app/src/tests/fixtures/utils/householdComparisonMocks.ts @@ -1,4 +1,4 @@ -import type { Household } from '@/types/ingredients/Household'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import type { MetadataState } from '@/types/metadata'; export const mockHousehold = (_netIncome: number = 50000): Household => ({ @@ -8,9 +8,9 @@ export const mockHousehold = (_netIncome: number = 50000): Household => ({ people: {}, households: {}, families: {}, - marital_units: {}, - tax_units: {}, - spm_units: {}, + maritalUnits: {}, + taxUnits: {}, + spmUnits: {}, }, }); diff --git a/app/src/tests/fixtures/utils/householdQueriesMocks.ts b/app/src/tests/fixtures/utils/householdQueriesMocks.ts index 0645a80c5..45b852778 100644 --- a/app/src/tests/fixtures/utils/householdQueriesMocks.ts +++ b/app/src/tests/fixtures/utils/householdQueriesMocks.ts @@ -1,5 +1,9 @@ import { CURRENT_YEAR } from '@/constants'; -import { Household, HouseholdPerson } from '@/types/ingredients/Household'; +import type { + AppHouseholdInputEnvelope as Household, + AppHouseholdInputPerson as HouseholdPerson, +} from '@/models/household/appTypes'; +import { getHouseholdYearValue } from '@/utils/householdDataAccess'; import { PersonWithName } from '@/utils/HouseholdQueries'; // ============= TEST CONSTANTS ============= @@ -397,7 +401,7 @@ export const verifyPersonWithName = ( ): void => { expect(actual.name).toBe(expectedName); if (expectedAge !== undefined) { - expect(actual.age[year]).toBe(expectedAge); + expect(getHouseholdYearValue(actual.age, year)).toBe(expectedAge); } }; diff --git a/app/src/tests/fixtures/utils/householdTableDataMocks.ts b/app/src/tests/fixtures/utils/householdTableDataMocks.ts index d254bbd40..6fb3a91b2 100644 --- a/app/src/tests/fixtures/utils/householdTableDataMocks.ts +++ b/app/src/tests/fixtures/utils/householdTableDataMocks.ts @@ -1,4 +1,4 @@ -import { Household } from '@/types/ingredients/Household'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; /** * Mock households for testing household table data utilities diff --git a/app/src/tests/fixtures/utils/householdValidationMocks.ts b/app/src/tests/fixtures/utils/householdValidationMocks.ts index ed747bda2..937c28f2b 100644 --- a/app/src/tests/fixtures/utils/householdValidationMocks.ts +++ b/app/src/tests/fixtures/utils/householdValidationMocks.ts @@ -1,6 +1,9 @@ import { CURRENT_YEAR } from '@/constants'; +import type { + AppHouseholdInputEnvelope as Household, + AppHouseholdInputPerson as HouseholdPerson, +} from '@/models/household/appTypes'; import { RootState } from '@/store'; -import { Household, HouseholdPerson } from '@/types/ingredients/Household'; import { ValidationError, ValidationResult, diff --git a/app/src/tests/fixtures/utils/householdValuesMocks.ts b/app/src/tests/fixtures/utils/householdValuesMocks.ts index bccbb25b6..c16e07cd5 100644 --- a/app/src/tests/fixtures/utils/householdValuesMocks.ts +++ b/app/src/tests/fixtures/utils/householdValuesMocks.ts @@ -1,5 +1,5 @@ import { CURRENT_YEAR } from '@/constants'; -import { Household } from '@/types/ingredients/Household'; +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import { MetadataState } from '@/types/metadata'; /** @@ -84,6 +84,7 @@ export const MOCK_HOUSEHOLD_DATA: Household = { householdData: { households: { 'your household': { + members: [], household_income: { [CURRENT_YEAR]: 50000, }, @@ -119,6 +120,7 @@ export const MOCK_HOUSEHOLD_DATA_REFORM: Household = { householdData: { households: { 'your household': { + members: [], household_income: { [CURRENT_YEAR]: 52000, }, @@ -154,6 +156,7 @@ export const MOCK_HOUSEHOLD_DATA_MULTI_PERIOD: Household = { householdData: { households: { 'your household': { + members: [], household_income: { '2024': 48000, [CURRENT_YEAR]: 50000, diff --git a/app/src/tests/fixtures/utils/populationCopyMocks.ts b/app/src/tests/fixtures/utils/populationCopyMocks.ts index 4c92e4849..86bfc05eb 100644 --- a/app/src/tests/fixtures/utils/populationCopyMocks.ts +++ b/app/src/tests/fixtures/utils/populationCopyMocks.ts @@ -29,7 +29,7 @@ export function mockPopulationWithComplexHousehold(): Population { members: ['person1', 'person2'], }, }, - tax_units: { + taxUnits: { tax_unit1: { members: ['person1', 'person2'], }, diff --git a/app/src/tests/fixtures/utils/populationMatchingMocks.ts b/app/src/tests/fixtures/utils/populationMatchingMocks.ts index 5aace9d0a..ee43e8ca6 100644 --- a/app/src/tests/fixtures/utils/populationMatchingMocks.ts +++ b/app/src/tests/fixtures/utils/populationMatchingMocks.ts @@ -1,5 +1,6 @@ import type { UserGeographicMetadataWithAssociation } from '@/hooks/useUserGeographic'; import type { UserHouseholdMetadataWithAssociation } from '@/hooks/useUserHousehold'; +import { Household as HouseholdModel } from '@/models/Household'; import type { Simulation } from '@/types/ingredients/Simulation'; /** @@ -39,13 +40,13 @@ export const mockHouseholdData: UserHouseholdMetadataWithAssociation[] = [ countryId: 'us', type: 'household', }, - household: { + household: HouseholdModel.fromDraft({ id: TEST_HOUSEHOLD_IDS.HOUSEHOLD_123, - country_id: 'us', - api_version: '1.0.0', - household_json: '{}' as any, - household_hash: 'hash123', - }, + countryId: 'us', + householdData: { + people: {}, + }, + }), isLoading: false, error: null, }, @@ -57,13 +58,13 @@ export const mockHouseholdData: UserHouseholdMetadataWithAssociation[] = [ countryId: 'us', type: 'household', }, - household: { + household: HouseholdModel.fromDraft({ id: TEST_HOUSEHOLD_IDS.HOUSEHOLD_456, - country_id: 'us', - api_version: '1.0.0', - household_json: '{}' as any, - household_hash: 'hash456', - }, + countryId: 'us', + householdData: { + people: {}, + }, + }), isLoading: false, error: null, }, @@ -127,20 +128,18 @@ export const mockHouseholdDataWithNumericMismatch: UserHouseholdMetadataWithAsso type: 'household', createdAt: new Date().toISOString(), }, - household: { + household: HouseholdModel.fromDraft({ id: TEST_HOUSEHOLD_IDS.NUMERIC_STRING_MATCH, // String ID - country_id: 'uk', - api_version: '2.39.0', - household_json: { + countryId: 'uk', + householdData: { people: {}, families: {}, - tax_units: {}, - spm_units: {}, + taxUnits: {}, + spmUnits: {}, households: {}, - marital_units: {}, + maritalUnits: {}, }, - household_hash: 'test-hash', - }, + }), isLoading: false, error: null, isError: false, diff --git a/app/src/tests/unit/adapters/HouseholdAdapter.test.ts b/app/src/tests/unit/adapters/HouseholdAdapter.test.ts deleted file mode 100644 index c6ba5ccb8..000000000 --- a/app/src/tests/unit/adapters/HouseholdAdapter.test.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { HouseholdAdapter } from '@/adapters/HouseholdAdapter'; -import { store } from '@/store'; -import { - mockEmptyHouseholdData, - mockEntityMetadata, - mockHouseholdData, - mockHouseholdDataWithMultipleEntities, - mockHouseholdDataWithUnknownEntity, - mockHouseholdMetadata, - mockHouseholdMetadataWithUnknownEntity, -} from '@/tests/fixtures/adapters/HouseholdAdapterMocks'; - -vi.mock('@/store', () => ({ - store: { - getState: vi.fn(), - }, -})); - -describe('HouseholdAdapter', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.spyOn(console, 'warn').mockImplementation(() => {}); - - (store.getState as any).mockReturnValue({ - metadata: { - entities: mockEntityMetadata, - }, - }); - }); - - describe('fromAPI', () => { - test('given valid household metadata from API then converts to internal Household format', () => { - const result = HouseholdAdapter.fromMetadata(mockHouseholdMetadata); - - expect(result).toEqual({ - id: '12345', - countryId: 'us', - householdData: { - people: mockHouseholdMetadata.household_json.people, - taxUnits: mockHouseholdMetadata.household_json.tax_units, - maritalUnits: mockHouseholdMetadata.household_json.marital_units, - spmUnits: mockHouseholdMetadata.household_json.spm_units, - households: mockHouseholdMetadata.household_json.households, - families: mockHouseholdMetadata.household_json.families, - }, - }); - }); - - test('given API response with snake_case entities then converts all to camelCase', () => { - const metadata = { - id: 123, - country_id: 'uk', - household_json: { - people: { person1: { age: { 2025: 30 } } }, - tax_units: { unit1: { members: ['person1'] } }, - marital_units: { unit1: { members: ['person1'] } }, - }, - }; - - const result = HouseholdAdapter.fromMetadata(metadata as any); - - expect(result.householdData).toHaveProperty('taxUnits'); - expect(result.householdData).toHaveProperty('maritalUnits'); - expect(result.householdData.taxUnits).toEqual(metadata.household_json.tax_units); - expect(result.householdData.maritalUnits).toEqual(metadata.household_json.marital_units); - }); - - test('given entity not in metadata then logs warning but includes it anyway', () => { - const result = HouseholdAdapter.fromMetadata(mockHouseholdMetadataWithUnknownEntity); - - expect(console.warn).toHaveBeenCalledWith( - 'Entity "unknown_entity" not found in metadata, including anyway' - ); - expect(result.householdData).toHaveProperty('unknownEntity'); - expect(result.householdData.unknownEntity).toEqual( - // @ts-expect-error - mockHouseholdMetadataWithUnknownEntity.household_json.unknown_entity - ); - }); - - test('given people entity then always includes it without validation', () => { - const metadata = { - id: 456, - country_id: 'us', - household_json: { - people: { - person1: { age: { 2025: 25 } }, - person2: { age: { 2025: 30 } }, - }, - }, - }; - - const result = HouseholdAdapter.fromMetadata(metadata as any); - - expect(result.householdData.people).toEqual(metadata.household_json.people); - expect(console.warn).not.toHaveBeenCalled(); - }); - - test('given empty household_json except people then returns only people', () => { - const metadata = { - id: 789, - country_id: 'ca', - household_json: { - people: {}, - }, - }; - - const result = HouseholdAdapter.fromMetadata(metadata as any); - - expect(result).toEqual({ - id: 789, - countryId: 'ca', - householdData: { - people: {}, - }, - }); - }); - }); - - describe('toCreationPayload', () => { - test('given household data then creates proper payload structure', () => { - const result = HouseholdAdapter.toCreationPayload(mockHouseholdData, 'us'); - - expect(result).toEqual({ - country_id: 'us', - data: { - people: mockHouseholdData.people, - tax_units: mockHouseholdData.taxUnits, - marital_units: mockHouseholdData.maritalUnits, - }, - }); - }); - - test('given household data with tax_units then converts to snake_case in payload', () => { - const householdData = { - people: { person1: { age: { 2025: 30 } } }, - taxUnits: { unit1: { members: ['person1'] } }, - }; - - const result = HouseholdAdapter.toCreationPayload(householdData as any, 'uk'); - - expect(result.data).toHaveProperty('tax_units'); - expect(result.data.tax_units).toEqual(householdData.taxUnits); - }); - - test('given camelCase taxUnits then converts to tax_units in payload', () => { - const householdData = { - people: {}, - taxUnits: { unit1: { head: 'person1' } }, - }; - - const result = HouseholdAdapter.toCreationPayload(householdData as any, 'us'); - - expect(result.data).toHaveProperty('tax_units'); - expect(result.data).not.toHaveProperty('taxUnits'); - }); - - test('given household data with multiple entities then payload includes all entities', () => { - const result = HouseholdAdapter.toCreationPayload( - mockHouseholdDataWithMultipleEntities, - 'us' - ); - - expect(result.data).toHaveProperty('people'); - expect(result.data).toHaveProperty('tax_units'); - expect(result.data).toHaveProperty('marital_units'); - expect(result.data).toHaveProperty('spm_units'); - }); - - test('given empty household data then creates minimal payload with only people', () => { - const result = HouseholdAdapter.toCreationPayload(mockEmptyHouseholdData, 'ca'); - - expect(result).toEqual({ - country_id: 'ca', - data: { - people: {}, - }, - }); - }); - - test('given entity not in metadata then toCreationPayload logs warning and uses snake_case', () => { - const result = HouseholdAdapter.toCreationPayload(mockHouseholdDataWithUnknownEntity, 'uk'); - - expect(console.warn).toHaveBeenCalledWith( - 'Entity "customEntity" not found in metadata, using snake_case "custom_entity"' - ); - expect(result.data).toHaveProperty('custom_entity'); - // @ts-expect-error - expect(result.data.custom_entity).toEqual(mockHouseholdDataWithUnknownEntity.customEntity); - }); - - test('given people entity then treats it as special case without conversion', () => { - const householdData = { - people: { - person1: { age: { 2025: 40 } }, - person2: { age: { 2025: 35 } }, - }, - }; - - const result = HouseholdAdapter.toCreationPayload(householdData as any, 'us'); - - expect(result.data.people).toEqual(householdData.people); - expect(console.warn).not.toHaveBeenCalled(); - }); - - test('given entity with matching plural in metadata then uses metadata plural form', () => { - const householdData = { - people: {}, - maritalUnits: { unit1: { members: ['person1', 'person2'] } }, - }; - - const result = HouseholdAdapter.toCreationPayload(householdData as any, 'uk'); - - expect(result.data).toHaveProperty('marital_units'); - expect(result.data.marital_units).toEqual(householdData.maritalUnits); - }); - }); - - describe('Edge cases and error handling', () => { - test('given metadata with no entities then still processes people', () => { - (store.getState as any).mockReturnValue({ - metadata: { entities: {} }, - }); - - const metadata = { - id: 999, - country_id: 'us', - household_json: { - people: { person1: { age: { 2025: 50 } } }, - }, - }; - - const result = HouseholdAdapter.fromMetadata(metadata as any); - - expect(result.householdData.people).toEqual(metadata.household_json.people); - }); - - test('given undefined metadata entities then handles gracefully', () => { - (store.getState as any).mockReturnValue({ - metadata: {}, - }); - - const metadata = { - id: 111, - country_id: 'ca', - household_json: { - people: { person1: {} }, - tax_units: { unit1: {} }, - }, - }; - - const result = HouseholdAdapter.fromMetadata(metadata as any); - - expect(result.householdData.people).toBeDefined(); - expect(result.householdData.taxUnits).toBeDefined(); - expect(console.warn).toHaveBeenCalled(); - }); - - test('given complex nested snake_case then converts correctly to camelCase', () => { - const metadata = { - id: 222, - country_id: 'uk', - household_json: { - people: {}, - very_long_entity_name: { data: 'test' }, - }, - }; - - const result = HouseholdAdapter.fromMetadata(metadata as any); - - expect(result.householdData).toHaveProperty('veryLongEntityName'); - expect(result.householdData.veryLongEntityName).toEqual({ data: 'test' }); - }); - - test('given complex camelCase then converts correctly to snake_case', () => { - const householdData = { - people: {}, - veryLongEntityName: { data: 'test' }, - }; - - const result = HouseholdAdapter.toCreationPayload(householdData as any, 'us'); - - expect(result.data).toHaveProperty('very_long_entity_name'); - // @ts-expect-error - expect(result.data.very_long_entity_name).toEqual({ data: 'test' }); - }); - }); -}); diff --git a/app/src/tests/unit/api/v2/householdCalculation.test.ts b/app/src/tests/unit/api/v2/householdCalculation.test.ts index d9afa9936..b128a2c03 100644 --- a/app/src/tests/unit/api/v2/householdCalculation.test.ts +++ b/app/src/tests/unit/api/v2/householdCalculation.test.ts @@ -5,12 +5,14 @@ import { getHouseholdCalculationJobStatusV2, pollHouseholdCalculationJobV2, type HouseholdCalculatePayload, - type V2HouseholdShape, + type HouseholdCalculationResult, + type V2CreateHouseholdEnvelope, } from '@/api/v2/householdCalculation'; import { createMockHouseholdJobResponse, createMockHouseholdJobStatusResponse, - createMockV2HouseholdShape, + createMockUkV2CreateHouseholdEnvelope, + createMockV2CreateHouseholdEnvelope, mockFetch404, mockFetchError, mockFetchSequence, @@ -30,53 +32,61 @@ describe('householdCalculation v2 API', () => { // ========================================================================== describe('calculationResultToHousehold', () => { - test('given result arrays then maps them to flat household shape', () => { + test('given US result arrays then preserves only the US household shape', () => { // Given - const result = { + const result: HouseholdCalculationResult = { person: [{ net_income: 45000 }], household: [{ total_tax: 5000 }], tax_unit: [{ income_tax: 3000 }], family: [{ benefits: 200 }], spm_unit: [{ poverty_gap: 0 }], marital_unit: [{ filing_status: 'single' }], - benunit: [{ uc_amount: 100 }], }; - const original = createMockV2HouseholdShape(); + const original = createMockV2CreateHouseholdEnvelope(); // When - const household = calculationResultToHousehold(result, original as V2HouseholdShape); + const household = calculationResultToHousehold(result, original); // Then + expect(household.country_id).toBe('us'); + if (household.country_id !== 'us') { + throw new Error('Expected US household envelope'); + } expect(household.people).toEqual([{ net_income: 45000 }]); - expect(household.household).toEqual({ total_tax: 5000 }); - expect(household.tax_unit).toEqual({ income_tax: 3000 }); - expect(household.family).toEqual({ benefits: 200 }); - expect(household.spm_unit).toEqual({ poverty_gap: 0 }); - expect(household.marital_unit).toEqual({ filing_status: 'single' }); - expect(household.benunit).toEqual({ uc_amount: 100 }); + expect(household.household).toEqual([{ total_tax: 5000 }]); + expect(household.tax_unit).toEqual([{ income_tax: 3000 }]); + expect(household.family).toEqual([{ benefits: 200 }]); + expect(household.spm_unit).toEqual([{ poverty_gap: 0 }]); + expect(household.marital_unit).toEqual([{ filing_status: 'single' }]); + expect('benunit' in household).toBe(false); }); - test('given result then preserves country_id and year from original household', () => { + test('given UK result then preserves only the UK household shape', () => { // Given - const result = { + const result: HouseholdCalculationResult = { person: [{ net_income: 45000 }], household: [{ total_tax: 5000 }], + benunit: [{ uc_amount: 100 }], }; - const original: V2HouseholdShape = { - country_id: 'uk', - year: 2025, - people: [{ age: 40 }], - }; + const original: V2CreateHouseholdEnvelope = createMockUkV2CreateHouseholdEnvelope(); // When const household = calculationResultToHousehold(result, original); // Then expect(household.country_id).toBe('uk'); - expect(household.year).toBe(2025); + if (household.country_id !== 'uk') { + throw new Error('Expected UK household envelope'); + } + expect(household.household).toEqual([{ total_tax: 5000 }]); + expect(household.benunit).toEqual([{ uc_amount: 100 }]); + expect('tax_unit' in household).toBe(false); + expect('family' in household).toBe(false); + expect('spm_unit' in household).toBe(false); + expect('marital_unit' in household).toBe(false); }); - test('given null or missing optional arrays then maps to undefined', () => { + test('given null optional US arrays then maps them to empty arrays', () => { // Given const result = { person: [{ net_income: 45000 }], @@ -87,17 +97,20 @@ describe('householdCalculation v2 API', () => { marital_unit: null, benunit: null, }; - const original = createMockV2HouseholdShape(); + const original = createMockV2CreateHouseholdEnvelope(); // When - const household = calculationResultToHousehold(result as any, original as V2HouseholdShape); + const household = calculationResultToHousehold(result as any, original); // Then - expect(household.tax_unit).toBeUndefined(); - expect(household.family).toBeUndefined(); - expect(household.spm_unit).toBeUndefined(); - expect(household.marital_unit).toBeUndefined(); - expect(household.benunit).toBeUndefined(); + expect(household.country_id).toBe('us'); + if (household.country_id !== 'us') { + throw new Error('Expected US household envelope'); + } + expect(household.tax_unit).toEqual([]); + expect(household.family).toEqual([]); + expect(household.spm_unit).toEqual([]); + expect(household.marital_unit).toEqual([]); }); }); @@ -112,12 +125,11 @@ describe('householdCalculation v2 API', () => { country_id: 'us', year: 2026, people: [{ age: 30, employment_income: 50000 }], - tax_unit: { members: ['person1'] }, - family: null, - spm_unit: null, - marital_unit: null, - household: null, - benunit: null, + tax_unit: [{ tax_unit_id: 0 }], + family: [], + spm_unit: [], + marital_unit: [], + household: [], }; const jobResponse = createMockHouseholdJobResponse(); vi.stubGlobal('fetch', mockFetchSuccess(jobResponse)); @@ -148,6 +160,11 @@ describe('householdCalculation v2 API', () => { country_id: 'us', year: 2026, people: [{ age: 30 }], + tax_unit: [], + family: [], + spm_unit: [], + marital_unit: [], + household: [], }; vi.stubGlobal('fetch', mockFetchError(422, 'Validation error')); @@ -158,6 +175,29 @@ describe('householdCalculation v2 API', () => { }); }); + describe('createHouseholdCalculationJobV2 with UK payload', () => { + test('given valid UK payload then POST omits US-only entity groups', async () => { + const payload: HouseholdCalculatePayload = { + country_id: 'uk', + year: 2026, + people: [{ age: 30, employment_income: 50000 }], + household: [{ household_id: 0 }], + benunit: [{ benunit_id: 0 }], + }; + const jobResponse = createMockHouseholdJobResponse(); + vi.stubGlobal('fetch', mockFetchSuccess(jobResponse)); + + await createHouseholdCalculationJobV2(payload); + + const requestInit = vi.mocked(fetch).mock.calls[0][1] as RequestInit; + expect(requestInit.body).toBe(JSON.stringify(payload)); + expect(requestInit.body).not.toContain('tax_unit'); + expect(requestInit.body).not.toContain('family'); + expect(requestInit.body).not.toContain('spm_unit'); + expect(requestInit.body).not.toContain('marital_unit'); + }); + }); + // ========================================================================== // getHouseholdCalculationJobStatusV2 // ========================================================================== diff --git a/app/src/tests/unit/api/v2/households.test.ts b/app/src/tests/unit/api/v2/households.test.ts index 2c3a38d93..2c62bfc5b 100644 --- a/app/src/tests/unit/api/v2/households.test.ts +++ b/app/src/tests/unit/api/v2/households.test.ts @@ -1,20 +1,19 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import type { V2HouseholdShape } from '@/api/v2/householdCalculation'; import { createHouseholdV2, deleteHouseholdV2, fetchHouseholdByIdV2, - householdToV2Request, listHouseholdsV2, - v2ResponseToHousehold, } from '@/api/v2/households'; +import type { V2CreateHouseholdEnvelope } from '@/models/household/v2Types'; import { createMockHouseholdV2Response, - createMockV2HouseholdShape, + createMockUkHouseholdV2Response, + createMockUkV2CreateHouseholdEnvelope, + createMockV2CreateHouseholdEnvelope, mockFetch404, mockFetchError, mockFetchSuccess, - TEST_COUNTRY_ID, TEST_IDS, } from '@/tests/fixtures/api/v2/shared'; @@ -25,110 +24,6 @@ describe('households v2 API', () => { vi.clearAllMocks(); }); - // ========================================================================== - // householdToV2Request - // ========================================================================== - - describe('householdToV2Request', () => { - test('given a V2HouseholdShape then maps all fields to a create request', () => { - // Given - const shape: V2HouseholdShape = { - ...createMockV2HouseholdShape(), - id: TEST_IDS.HOUSEHOLD_ID, - }; - - // When - const request = householdToV2Request(shape); - - // Then - expect(request).toEqual({ - country_id: TEST_COUNTRY_ID, - year: 2026, - label: 'Test household', - people: [{ age: 30, employment_income: 50000 }], - tax_unit: { members: ['person1'] }, - family: null, - spm_unit: null, - marital_unit: null, - household: null, - benunit: null, - }); - // id should not appear in the create request - expect(request).not.toHaveProperty('id'); - }); - - test('given undefined optional fields then null-coalesces them to null', () => { - // Given - const shape: V2HouseholdShape = { - country_id: 'us', - year: 2026, - people: [{ age: 25 }], - // label, tax_unit, family, etc. are all undefined - }; - - // When - const request = householdToV2Request(shape); - - // Then - expect(request.label).toBeNull(); - expect(request.tax_unit).toBeNull(); - expect(request.family).toBeNull(); - expect(request.spm_unit).toBeNull(); - expect(request.marital_unit).toBeNull(); - expect(request.household).toBeNull(); - expect(request.benunit).toBeNull(); - }); - }); - - // ========================================================================== - // v2ResponseToHousehold - // ========================================================================== - - describe('v2ResponseToHousehold', () => { - test('given an API response then maps id, country_id, and data fields', () => { - // Given - const response = createMockHouseholdV2Response(); - - // When - const household = v2ResponseToHousehold(response as any); - - // Then - expect(household.id).toBe(TEST_IDS.HOUSEHOLD_ID); - expect(household.country_id).toBe(TEST_COUNTRY_ID); - expect(household.year).toBe(2026); - expect(household.label).toBe('Test household'); - expect(household.people).toEqual([{ age: 30, employment_income: 50000 }]); - expect(household.tax_unit).toEqual({ members: ['person1'] }); - }); - - test('given null optional fields in response then converts them to undefined', () => { - // Given - const response = createMockHouseholdV2Response(); - // family, spm_unit, marital_unit, household, benunit are null in mock - - // When - const household = v2ResponseToHousehold(response as any); - - // Then - expect(household.family).toBeUndefined(); - expect(household.spm_unit).toBeUndefined(); - expect(household.marital_unit).toBeUndefined(); - expect(household.household).toBeUndefined(); - expect(household.benunit).toBeUndefined(); - }); - - test('given null label in response then converts it to undefined', () => { - // Given - const response = { ...createMockHouseholdV2Response(), label: null }; - - // When - const household = v2ResponseToHousehold(response as any); - - // Then - expect(household.label).toBeUndefined(); - }); - }); - // ========================================================================== // createHouseholdV2 // ========================================================================== @@ -136,12 +31,12 @@ describe('households v2 API', () => { describe('createHouseholdV2', () => { test('given valid household then POST succeeds with correct URL and body', async () => { // Given - const shape = createMockV2HouseholdShape(); + const request: V2CreateHouseholdEnvelope = createMockV2CreateHouseholdEnvelope(); const apiResponse = createMockHouseholdV2Response(); vi.stubGlobal('fetch', mockFetchSuccess(apiResponse)); // When - const result = await createHouseholdV2(shape as V2HouseholdShape); + const result = await createHouseholdV2(request); // Then expect(fetch).toHaveBeenCalledOnce(); @@ -155,20 +50,47 @@ describe('households v2 API', () => { }, }) ); - expect(result.id).toBe(TEST_IDS.HOUSEHOLD_ID); - expect(result.country_id).toBe(TEST_COUNTRY_ID); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/households/'), + expect.objectContaining({ + body: JSON.stringify(request), + }) + ); + expect(result).toEqual(apiResponse); }); test('given API returns error then throws with status and message', async () => { // Given - const shape = createMockV2HouseholdShape(); + const request: V2CreateHouseholdEnvelope = createMockV2CreateHouseholdEnvelope(); vi.stubGlobal('fetch', mockFetchError(500, 'Internal Server Error')); // When / Then - await expect(createHouseholdV2(shape as V2HouseholdShape)).rejects.toThrow( + await expect(createHouseholdV2(request)).rejects.toThrow( 'createHouseholdV2: 500 Internal Server Error' ); }); + + test('given valid UK household then POST succeeds with a UK-shaped payload', async () => { + const request: V2CreateHouseholdEnvelope = createMockUkV2CreateHouseholdEnvelope(); + const apiResponse = createMockUkHouseholdV2Response(); + vi.stubGlobal('fetch', mockFetchSuccess(apiResponse)); + + const result = await createHouseholdV2(request); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/households/'), + expect.objectContaining({ + body: JSON.stringify(request), + }) + ); + expect(result).toEqual(apiResponse); + expect(result.country_id).toBe('uk'); + if (result.country_id !== 'uk') { + throw new Error('Expected UK stored household'); + } + expect(result.benunit).toEqual([{ benunit_id: 0 }]); + expect('tax_unit' in result).toBe(false); + }); }); // ========================================================================== @@ -193,7 +115,7 @@ describe('households v2 API', () => { headers: { Accept: 'application/json' }, }) ); - expect(result.id).toBe(TEST_IDS.HOUSEHOLD_ID); + expect(result).toEqual(apiResponse); }); test('given 404 response then throws not found error', async () => { @@ -215,6 +137,21 @@ describe('households v2 API', () => { `fetchHouseholdByIdV2(${TEST_IDS.HOUSEHOLD_ID}): 500 Server Error` ); }); + + test('given a UK household response then GET returns the UK household shape', async () => { + const apiResponse = createMockUkHouseholdV2Response(); + vi.stubGlobal('fetch', mockFetchSuccess(apiResponse)); + + const result = await fetchHouseholdByIdV2(TEST_IDS.HOUSEHOLD_ID); + + expect(result).toEqual(apiResponse); + expect(result.country_id).toBe('uk'); + if (result.country_id !== 'uk') { + throw new Error('Expected UK stored household'); + } + expect(result.benunit).toEqual([{ benunit_id: 0 }]); + expect('tax_unit' in result).toBe(false); + }); }); // ========================================================================== @@ -240,7 +177,7 @@ describe('households v2 API', () => { expect(calledUrl).toContain('limit=10'); expect(calledUrl).toContain('offset=5'); expect(result).toHaveLength(1); - expect(result[0].id).toBe(TEST_IDS.HOUSEHOLD_ID); + expect(result[0]).toEqual(apiResponse[0]); }); test('given no filters then calls URL without query string', async () => { @@ -257,6 +194,18 @@ describe('households v2 API', () => { expect(calledUrl).not.toContain('?'); expect(result).toHaveLength(1); }); + + test('given UK filter then returns UK household rows unchanged', async () => { + const apiResponse = [createMockUkHouseholdV2Response()]; + vi.stubGlobal('fetch', mockFetchSuccess(apiResponse)); + + const result = await listHouseholdsV2({ country_id: 'uk' }); + + const calledUrl = vi.mocked(fetch).mock.calls[0][0] as string; + expect(calledUrl).toContain('country_id=uk'); + expect(result).toEqual(apiResponse); + expect(result[0].country_id).toBe('uk'); + }); }); // ========================================================================== diff --git a/app/src/tests/unit/api/v2/userHouseholdAssociations.test.ts b/app/src/tests/unit/api/v2/userHouseholdAssociations.test.ts index 5ed205173..2d039a049 100644 --- a/app/src/tests/unit/api/v2/userHouseholdAssociations.test.ts +++ b/app/src/tests/unit/api/v2/userHouseholdAssociations.test.ts @@ -6,6 +6,7 @@ import { fetchUserHouseholdAssociationsV2, fromV2Response, toV2CreateRequest, + toV2UpdateRequest, updateUserHouseholdAssociationV2, } from '@/api/v2/userHouseholdAssociations'; import { @@ -221,6 +222,18 @@ describe('userHouseholdAssociations', () => { // ========================================================================== describe('updateUserHouseholdAssociationV2', () => { + test('given camelCase householdId then it maps to snake_case API update payload', () => { + expect( + toV2UpdateRequest({ + label: 'Updated household', + householdId: TEST_IDS.HOUSEHOLD_ID, + }) + ).toEqual({ + label: 'Updated household', + household_id: TEST_IDS.HOUSEHOLD_ID, + }); + }); + test('given a successful PUT then it returns the updated association', async () => { // Given const mockResponse = { @@ -238,7 +251,10 @@ describe('userHouseholdAssociations', () => { expect(result.label).toBe('Updated household'); expect(fetch).toHaveBeenCalledWith( expect.stringContaining(`/user-household-associations/${TEST_IDS.ASSOCIATION_ID}`), - expect.objectContaining({ method: 'PUT' }) + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ label: 'Updated household' }), + }) ); }); }); diff --git a/app/src/tests/unit/hooks/replaceHouseholdBaseForAssociation.test.ts b/app/src/tests/unit/hooks/replaceHouseholdBaseForAssociation.test.ts new file mode 100644 index 000000000..7d74aea3f --- /dev/null +++ b/app/src/tests/unit/hooks/replaceHouseholdBaseForAssociation.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test, vi } from 'vitest'; +import { createHousehold } from '@/api/household'; +import { replaceHouseholdBaseForAssociation } from '@/hooks/household/replaceHouseholdBaseForAssociation'; +import { + shadowCreateHousehold, + shadowUpdateUserHouseholdAssociation, +} from '@/libs/migration/householdShadow'; +import { createMockHouseholdData } from '@/tests/fixtures/models/shared'; +import type { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; + +vi.mock('@/api/household', () => ({ + createHousehold: vi.fn(), +})); + +vi.mock('@/libs/migration/householdShadow', () => ({ + shadowCreateHousehold: vi.fn(), + shadowUpdateUserHouseholdAssociation: vi.fn(), +})); + +const TEST_COUNTRY_ID = 'us' as const; +const TEST_ASSOCIATION_ID = 'suh-abc123'; +const TEST_CREATED_HOUSEHOLD_ID = '789'; + +const association: UserHouseholdPopulation = { + type: 'household', + id: TEST_ASSOCIATION_ID, + userId: 'anonymous', + householdId: '456', + countryId: TEST_COUNTRY_ID, + label: 'Saved household', + createdAt: '2026-04-16T10:00:00Z', + isCreated: true, +}; + +const nextHousehold = { + countryId: TEST_COUNTRY_ID, + householdData: createMockHouseholdData({ + id: 'draft-replacement', + countryId: TEST_COUNTRY_ID, + label: association.label, + }).data, +}; + +describe('replaceHouseholdBaseForAssociation', () => { + test('given v1 association update fails after create then it surfaces the orphaned household id and skips shadow writes', async () => { + vi.mocked(createHousehold).mockResolvedValue({ + result: { household_id: TEST_CREATED_HOUSEHOLD_ID }, + }); + const store = { + update: vi.fn().mockRejectedValue(new Error('local store unavailable')), + }; + + await expect( + replaceHouseholdBaseForAssociation({ + association, + nextHousehold, + store, + }) + ).rejects.toThrow( + `Failed to update household association ${TEST_ASSOCIATION_ID} after creating replacement household ${TEST_CREATED_HOUSEHOLD_ID}. The replacement household may now be orphaned. Original error: local store unavailable` + ); + + expect(createHousehold).toHaveBeenCalledTimes(1); + expect(store.update).toHaveBeenCalledWith(TEST_ASSOCIATION_ID, { + householdId: TEST_CREATED_HOUSEHOLD_ID, + }); + expect(shadowCreateHousehold).not.toHaveBeenCalled(); + expect(shadowUpdateUserHouseholdAssociation).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/tests/unit/hooks/useCreateHousehold.test.tsx b/app/src/tests/unit/hooks/useCreateHousehold.test.tsx index ab6c8c803..8830025f6 100644 --- a/app/src/tests/unit/hooks/useCreateHousehold.test.tsx +++ b/app/src/tests/unit/hooks/useCreateHousehold.test.tsx @@ -6,6 +6,7 @@ import { createHousehold } from '@/api/household'; // Now import the actual implementations import { useCreateHousehold } from '@/hooks/useCreateHousehold'; import { useCreateHouseholdAssociation } from '@/hooks/useUserHousehold'; +import { shadowCreateHouseholdAndAssociation } from '@/libs/migration/householdShadow'; // Import fixtures first import { CONSOLE_MESSAGES, @@ -25,6 +26,10 @@ vi.mock('@/api/household', () => ({ createHousehold: vi.fn(), })); +vi.mock('@/libs/migration/householdShadow', () => ({ + shadowCreateHouseholdAndAssociation: vi.fn(), +})); + vi.mock('@/constants', () => ({ MOCK_USER_ID: 'test-user-123', CURRENT_YEAR: '2025', @@ -55,6 +60,7 @@ describe('useCreateHousehold', () => { (useCreateHouseholdAssociation as any).mockReturnValue({ mutateAsync: mockCreateHouseholdAssociationMutateAsync, }); + (shadowCreateHouseholdAndAssociation as any).mockResolvedValue(undefined); // Set default mock implementations mockCreateHousehold.mockResolvedValue(mockCreateHouseholdResponse); @@ -97,6 +103,7 @@ describe('useCreateHousehold', () => { countryId: mockHouseholdCreationPayload.country_id, label: TEST_LABELS.HOUSEHOLD, }); + expect(shadowCreateHouseholdAndAssociation).toHaveBeenCalledTimes(1); // Verify response expect(response).toEqual(mockCreateHouseholdResponse); @@ -137,6 +144,30 @@ describe('useCreateHousehold', () => { label: customLabel, }); }); + + test('given successful create then it triggers household shadow create with the v1 household and association', async () => { + const { result } = renderHook(() => useCreateHousehold(TEST_LABELS.HOUSEHOLD), { wrapper }); + + await result.current.createHousehold(mockHouseholdCreationPayload); + + await waitFor(() => { + expect(shadowCreateHouseholdAndAssociation).toHaveBeenCalledTimes(1); + }); + + const shadowArgs = vi.mocked(shadowCreateHouseholdAndAssociation).mock.calls[0][0]; + expect(shadowArgs.v1HouseholdId).toBe(TEST_IDS.HOUSEHOLD_ID); + expect(shadowArgs.v1Association).toEqual( + expect.objectContaining({ + userId: TEST_IDS.USER_ID, + householdId: TEST_IDS.HOUSEHOLD_ID, + label: TEST_LABELS.HOUSEHOLD, + }) + ); + expect(shadowArgs.v1Household.toV1CreationPayload()).toEqual({ + ...mockHouseholdCreationPayload, + label: TEST_LABELS.HOUSEHOLD, + }); + }); }); describe('error handling', () => { @@ -174,6 +205,12 @@ describe('useCreateHousehold', () => { CONSOLE_MESSAGES.ASSOCIATION_ERROR, associationError ); + expect(shadowCreateHouseholdAndAssociation).toHaveBeenCalledWith( + expect.objectContaining({ + v1HouseholdId: TEST_IDS.HOUSEHOLD_ID, + v1Association: undefined, + }) + ); // Household creation should succeed (check only first argument) expect(mockCreateHousehold).toHaveBeenCalled(); diff --git a/app/src/tests/unit/hooks/useSaveSharedReport.test.tsx b/app/src/tests/unit/hooks/useSaveSharedReport.test.tsx index 40d3bb16c..f04a33556 100644 --- a/app/src/tests/unit/hooks/useSaveSharedReport.test.tsx +++ b/app/src/tests/unit/hooks/useSaveSharedReport.test.tsx @@ -5,6 +5,10 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { useSaveSharedReport } from '@/hooks/useSaveSharedReport'; +import { + shadowCreateHouseholdAndAssociation, + shadowCreateUserHouseholdAssociation, +} from '@/libs/migration/householdShadow'; import { getV2Id } from '@/libs/migration/idMapping'; import { sendMigrationLog } from '@/libs/migration/migrationLogTransport'; import { @@ -16,13 +20,16 @@ import { createMockReportStore, CURRENT_LAW_ID, MOCK_EXISTING_USER_REPORT, + MOCK_HOUSEHOLDS, MOCK_POLICIES, MOCK_SAVE_SHARE_DATA, + MOCK_SAVED_USER_HOUSEHOLD, MOCK_SAVED_USER_POLICY, MOCK_SAVED_USER_REPORT, MOCK_SHARE_DATA_WITH_CURRENT_LAW, MOCK_SHARE_DATA_WITH_HOUSEHOLD, MOCK_SHARE_DATA_WITHOUT_LABEL, + MOCK_SHARED_V1_HOUSEHOLD_MODEL, TEST_ERRORS, TEST_IDS, } from '@/tests/fixtures/hooks/useSaveSharedReportMocks'; @@ -47,6 +54,11 @@ vi.mock('@/libs/migration/idMapping', () => ({ getV2Id: vi.fn(), })); +vi.mock('@/libs/migration/householdShadow', () => ({ + shadowCreateHouseholdAndAssociation: vi.fn(), + shadowCreateUserHouseholdAssociation: vi.fn(), +})); + vi.mock('@/libs/migration/policyShadow', () => ({ shadowCreatePolicyAndAssociation: vi.fn(), shadowCreateUserPolicyAssociation: vi.fn(), @@ -103,7 +115,7 @@ describe('useSaveSharedReport', () => { // Reset mock implementations mockCreateSimulation.mutateAsync.mockResolvedValue({}); mockCreatePolicy.mutateAsync.mockResolvedValue(MOCK_SAVED_USER_POLICY); - mockCreateHousehold.mutateAsync.mockResolvedValue({}); + mockCreateHousehold.mutateAsync.mockResolvedValue(MOCK_SAVED_USER_HOUSEHOLD); mockCreateGeography.mutateAsync.mockResolvedValue({}); mockCreateReport.mutateAsync.mockResolvedValue(MOCK_SAVED_USER_REPORT); mockReportStore.findByUserReportId.mockResolvedValue(null); @@ -285,6 +297,90 @@ describe('useSaveSharedReport', () => { expect(mockCreateGeography.mutateAsync).not.toHaveBeenCalled(); }); + test('given saved shared household without v2 mapping then shadow creates v2 household and association', async () => { + const store = createMockStore(); + const wrapper = createWrapper(store); + + const { result } = renderHook(() => useSaveSharedReport(), { wrapper }); + + await act(async () => { + await result.current.saveSharedReport(MOCK_SHARE_DATA_WITH_HOUSEHOLD, [], MOCK_HOUSEHOLDS); + }); + + expect(shadowCreateHouseholdAndAssociation).toHaveBeenCalledTimes(1); + const [callArgs] = vi.mocked(shadowCreateHouseholdAndAssociation).mock.calls[0]; + expect(callArgs.v1HouseholdId).toBe(TEST_IDS.HOUSEHOLD); + expect(callArgs.v1Association).toEqual(MOCK_SAVED_USER_HOUSEHOLD); + expect(callArgs.v1Household.householdData).toEqual(MOCK_HOUSEHOLDS[0].householdData); + expect(shadowCreateUserHouseholdAssociation).not.toHaveBeenCalled(); + }); + + test('given fetched v1 household model without v2 mapping then shadow creates from the runtime model shape', async () => { + const store = createMockStore(); + const wrapper = createWrapper(store); + + const { result } = renderHook(() => useSaveSharedReport(), { wrapper }); + + await act(async () => { + await result.current.saveSharedReport( + MOCK_SHARE_DATA_WITH_HOUSEHOLD, + [], + [MOCK_SHARED_V1_HOUSEHOLD_MODEL] + ); + }); + + expect(shadowCreateHouseholdAndAssociation).toHaveBeenCalledTimes(1); + const [callArgs] = vi.mocked(shadowCreateHouseholdAndAssociation).mock.calls[0]; + expect(callArgs.v1HouseholdId).toBe(TEST_IDS.HOUSEHOLD); + expect(callArgs.v1Association).toEqual(MOCK_SAVED_USER_HOUSEHOLD); + expect(callArgs.v1Household.id).toBe(TEST_IDS.HOUSEHOLD); + expect(callArgs.v1Household.countryId).toBe('uk'); + expect(callArgs.v1Household.label).toBe('My Household'); + expect(callArgs.v1Household.householdData).toEqual( + MOCK_SHARED_V1_HOUSEHOLD_MODEL.householdData + ); + }); + + test('given saved shared household without details then emits skipped remote log', async () => { + const store = createMockStore(); + const wrapper = createWrapper(store); + + const { result } = renderHook(() => useSaveSharedReport(), { wrapper }); + + await act(async () => { + await result.current.saveSharedReport(MOCK_SHARE_DATA_WITH_HOUSEHOLD); + }); + + expect(sendMigrationLog).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'event', + prefix: 'HouseholdMigration', + operation: 'CREATE', + status: 'SKIPPED', + }) + ); + }); + + test('given saved shared household with existing v2 mapping then shadows only the association', async () => { + vi.mocked(getV2Id).mockImplementation(((entityType: string) => + entityType === 'Household' ? '550e8400-e29b-41d4-a716-446655440123' : null) as any); + + const store = createMockStore(); + const wrapper = createWrapper(store); + + const { result } = renderHook(() => useSaveSharedReport(), { wrapper }); + + await act(async () => { + await result.current.saveSharedReport(MOCK_SHARE_DATA_WITH_HOUSEHOLD, [], MOCK_HOUSEHOLDS); + }); + + expect(shadowCreateUserHouseholdAssociation).toHaveBeenCalledWith( + MOCK_SAVED_USER_HOUSEHOLD, + '550e8400-e29b-41d4-a716-446655440123' + ); + expect(shadowCreateHouseholdAndAssociation).not.toHaveBeenCalled(); + }); + test('given shareData without label then generates default label', async () => { // Given const store = createMockStore(); diff --git a/app/src/tests/unit/hooks/useUserHousehold.dualWrite.test.tsx b/app/src/tests/unit/hooks/useUserHousehold.dualWrite.test.tsx new file mode 100644 index 000000000..e08e76ecd --- /dev/null +++ b/app/src/tests/unit/hooks/useUserHousehold.dualWrite.test.tsx @@ -0,0 +1,230 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { createHousehold } from '@/api/household'; +import { createHouseholdV2 } from '@/api/v2'; +import { + createUserHouseholdAssociationV2, + updateUserHouseholdAssociationV2, +} from '@/api/v2/userHouseholdAssociations'; +import { useUpdateHouseholdAssociation } from '@/hooks/useUserHousehold'; +import { getV2Id, setV2Id } from '@/libs/migration/idMapping'; +import { Household as HouseholdModel } from '@/models/Household'; +import type { AppHouseholdInputEnvelope } from '@/models/household/appTypes'; +import { createMockHouseholdData } from '@/tests/fixtures/models/shared'; +import type { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; + +const TEST_USER_ID = 'anonymous'; +const TEST_COUNTRY_ID = 'us' as const; +const TEST_LABEL = 'Saved household'; +const TEST_V1_ASSOC_ID = 'suh-abc123'; +const TEST_OLD_V1_HOUSEHOLD_ID = '456'; +const TEST_NEW_V1_HOUSEHOLD_ID = '789'; +const TEST_V2_ASSOC_ID = 'dd0e8400-e29b-41d4-a716-446655440008'; +const TEST_OLD_V2_HOUSEHOLD_ID = '770e8400-e29b-41d4-a716-446655440002'; +const TEST_NEW_V2_HOUSEHOLD_ID = '770e8400-e29b-41d4-a716-446655440099'; +const TEST_V2_USER_ID = 'c93a763d-8d9f-4ab8-b04f-2fbba0183f35'; + +const initialAssociation: UserHouseholdPopulation = { + type: 'household', + id: TEST_V1_ASSOC_ID, + userId: TEST_USER_ID, + householdId: TEST_OLD_V1_HOUSEHOLD_ID, + countryId: TEST_COUNTRY_ID, + label: TEST_LABEL, + createdAt: '2026-04-09T12:00:00Z', + isCreated: true, +}; + +const renamedAssociation: UserHouseholdPopulation = { + ...initialAssociation, + label: 'Renamed household', + updatedAt: '2026-04-09T12:10:00Z', +}; + +const replacedAssociation: UserHouseholdPopulation = { + ...initialAssociation, + householdId: TEST_NEW_V1_HOUSEHOLD_ID, + updatedAt: '2026-04-09T12:20:00Z', +}; + +const nextHousehold: AppHouseholdInputEnvelope = { + countryId: TEST_COUNTRY_ID, + householdData: createMockHouseholdData({ + id: 'draft-replacement', + countryId: TEST_COUNTRY_ID, + label: TEST_LABEL, + }).data, +}; + +const { mockStoreCreate, mockStoreUpdate, mockStoreFindByUser, mockStoreFindById } = vi.hoisted( + () => ({ + mockStoreCreate: vi.fn(), + mockStoreUpdate: vi.fn(), + mockStoreFindByUser: vi.fn().mockResolvedValue([]), + mockStoreFindById: vi.fn().mockResolvedValue(null), + }) +); + +vi.mock('@/api/householdAssociation', () => ({ + ApiHouseholdStore: vi.fn().mockImplementation(() => ({ + create: mockStoreCreate, + update: mockStoreUpdate, + findByUser: mockStoreFindByUser, + findById: mockStoreFindById, + })), + LocalStorageHouseholdStore: vi.fn().mockImplementation(() => ({ + create: mockStoreCreate, + update: mockStoreUpdate, + findByUser: mockStoreFindByUser, + findById: mockStoreFindById, + })), +})); + +vi.mock('@/api/household', () => ({ + fetchHouseholdById: vi.fn(), + createHousehold: vi.fn(), +})); + +vi.mock('@/api/v2', () => ({ + createHouseholdV2: vi.fn(), +})); + +vi.mock('@/api/v2/userHouseholdAssociations', () => ({ + createUserHouseholdAssociationV2: vi.fn(), + updateUserHouseholdAssociationV2: vi.fn(), +})); + +vi.mock('@/hooks/useCurrentCountry', () => ({ + useCurrentCountry: () => 'us', +})); + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } }, + }); +} + +function createWrapper(queryClient: QueryClient) { + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +describe('useUpdateHouseholdAssociation dual-write', () => { + let queryClient: QueryClient; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + localStorage.clear(); + queryClient = createQueryClient(); + + setV2Id('User', TEST_USER_ID, TEST_V2_USER_ID); + setV2Id('UserHousehold', TEST_V1_ASSOC_ID, TEST_V2_ASSOC_ID); + setV2Id('Household', TEST_OLD_V1_HOUSEHOLD_ID, TEST_OLD_V2_HOUSEHOLD_ID); + + vi.mocked(updateUserHouseholdAssociationV2).mockResolvedValue({ + ...renamedAssociation, + userId: TEST_V2_USER_ID, + householdId: TEST_OLD_V2_HOUSEHOLD_ID, + id: TEST_V2_ASSOC_ID, + }); + vi.mocked(createUserHouseholdAssociationV2).mockResolvedValue({ + ...initialAssociation, + userId: TEST_V2_USER_ID, + householdId: TEST_OLD_V2_HOUSEHOLD_ID, + id: TEST_V2_ASSOC_ID, + }); + }); + + test('given label-only edit then it updates the existing v2 association without creating a new household', async () => { + mockStoreUpdate.mockResolvedValue(renamedAssociation); + + const { result } = renderHook(() => useUpdateHouseholdAssociation(), { + wrapper: createWrapper(queryClient), + }); + + await act(async () => { + await result.current.mutateAsync({ + userHouseholdId: TEST_V1_ASSOC_ID, + updates: { label: 'Renamed household' }, + }); + }); + + expect(createHousehold).not.toHaveBeenCalled(); + await waitFor(() => { + expect(updateUserHouseholdAssociationV2).toHaveBeenCalledWith(TEST_V2_ASSOC_ID, { + label: 'Renamed household', + householdId: TEST_OLD_V2_HOUSEHOLD_ID, + }); + }); + }); + + test('given content edit then it creates a new base household and preserves the association id', async () => { + mockStoreUpdate.mockResolvedValue(replacedAssociation); + vi.mocked(createHousehold).mockResolvedValue({ + result: { household_id: TEST_NEW_V1_HOUSEHOLD_ID }, + }); + vi.mocked(createHouseholdV2).mockResolvedValue({ + ...HouseholdModel.fromDraft({ + countryId: TEST_COUNTRY_ID, + householdData: nextHousehold.householdData, + label: TEST_LABEL, + }).toV2CreateEnvelope(), + id: TEST_NEW_V2_HOUSEHOLD_ID, + created_at: '2026-04-09T12:20:00Z', + updated_at: '2026-04-09T12:20:00Z', + }); + vi.mocked(updateUserHouseholdAssociationV2).mockResolvedValue({ + ...replacedAssociation, + userId: TEST_V2_USER_ID, + householdId: TEST_NEW_V2_HOUSEHOLD_ID, + id: TEST_V2_ASSOC_ID, + }); + + const { result } = renderHook(() => useUpdateHouseholdAssociation(), { + wrapper: createWrapper(queryClient), + }); + + let updatedAssociation: UserHouseholdPopulation | undefined; + await act(async () => { + updatedAssociation = await result.current.mutateAsync({ + userHouseholdId: TEST_V1_ASSOC_ID, + association: initialAssociation, + updates: {}, + nextHousehold, + }); + }); + + expect(createHousehold).toHaveBeenCalledWith( + expect.objectContaining({ + country_id: TEST_COUNTRY_ID, + }) + ); + expect(mockStoreUpdate).toHaveBeenCalledWith(TEST_V1_ASSOC_ID, { + householdId: TEST_NEW_V1_HOUSEHOLD_ID, + }); + expect(updatedAssociation).toEqual( + expect.objectContaining({ + id: TEST_V1_ASSOC_ID, + householdId: TEST_NEW_V1_HOUSEHOLD_ID, + }) + ); + + await waitFor(() => { + expect(createHouseholdV2).toHaveBeenCalledTimes(1); + }); + expect(createUserHouseholdAssociationV2).not.toHaveBeenCalled(); + await waitFor(() => { + expect(updateUserHouseholdAssociationV2).toHaveBeenCalledWith(TEST_V2_ASSOC_ID, { + label: TEST_LABEL, + householdId: TEST_NEW_V2_HOUSEHOLD_ID, + }); + }); + + expect(getV2Id('Household', TEST_NEW_V1_HOUSEHOLD_ID)).toBe(TEST_NEW_V2_HOUSEHOLD_ID); + expect(getV2Id('UserHousehold', TEST_V1_ASSOC_ID)).toBe(TEST_V2_ASSOC_ID); + }); +}); diff --git a/app/src/tests/unit/hooks/useUserHousehold.test.tsx b/app/src/tests/unit/hooks/useUserHousehold.test.tsx index 9b204c058..6be755693 100644 --- a/app/src/tests/unit/hooks/useUserHousehold.test.tsx +++ b/app/src/tests/unit/hooks/useUserHousehold.test.tsx @@ -15,6 +15,7 @@ import { useUserHouseholds, useUserHouseholdStore, } from '@/hooks/useUserHousehold'; +import { Household as HouseholdModel } from '@/models/Household'; import { createMockQueryClient, GEO_CONSTANTS, @@ -303,10 +304,13 @@ describe('useUserHousehold hooks', () => { expect(result.current.data).toBeDefined(); expect(result.current.data).toHaveLength(2); // Two households in mock list - // Verify each household has association and metadata + // Verify each household has association and canonical model data const firstHousehold = result.current.data![0]; expect(firstHousehold.association).toEqual(mockUserHouseholdPopulationList[0]); - expect(firstHousehold.household).toEqual(mockHouseholdMetadata); + expect(firstHousehold.household).toBeInstanceOf(HouseholdModel); + expect(firstHousehold.household?.householdData).toEqual( + HouseholdModel.fromV1Metadata(mockHouseholdMetadata).householdData + ); expect(firstHousehold.isLoading).toBe(false); expect(firstHousehold.error).toBeNull(); }); @@ -362,7 +366,10 @@ describe('useUserHousehold hooks', () => { expect(result.current.data).toBeDefined(); // First household should have data - expect(result.current.data![0].household).toEqual(mockHouseholdMetadata); + expect(result.current.data![0].household).toBeInstanceOf(HouseholdModel); + expect(result.current.data![0].household?.householdData).toEqual( + HouseholdModel.fromV1Metadata(mockHouseholdMetadata).householdData + ); // Second household should have error expect(result.current.data![1].error).toBeDefined(); diff --git a/app/src/tests/unit/libs/migration/householdShadow.test.ts b/app/src/tests/unit/libs/migration/householdShadow.test.ts new file mode 100644 index 000000000..676a4c82e --- /dev/null +++ b/app/src/tests/unit/libs/migration/householdShadow.test.ts @@ -0,0 +1,374 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { createHouseholdV2 } from '@/api/v2'; +import { + createUserHouseholdAssociationV2, + fetchUserHouseholdAssociationByIdV2, + updateUserHouseholdAssociationV2, +} from '@/api/v2/userHouseholdAssociations'; +import { logMigrationComparison } from '@/libs/migration/comparisonLogger'; +import { + shadowCreateHouseholdAndAssociation, + shadowUpdateUserHouseholdAssociation, +} from '@/libs/migration/householdShadow'; +import { + getV2AssociationTargetId, + getV2Id, + setV2AssociationTargetId, + setV2Id, +} from '@/libs/migration/idMapping'; +import { sendMigrationLog } from '@/libs/migration/migrationLogTransport'; +import { Household } from '@/models/Household'; +import { + createMockHouseholdData, + createMockHouseholdV2Response, +} from '@/tests/fixtures/models/shared'; +import type { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; + +vi.mock('@/api/v2', () => ({ + createHouseholdV2: vi.fn(), +})); + +vi.mock('@/api/v2/userHouseholdAssociations', () => ({ + createUserHouseholdAssociationV2: vi.fn(), + fetchUserHouseholdAssociationByIdV2: vi.fn(), + updateUserHouseholdAssociationV2: vi.fn(), +})); + +vi.mock('@/libs/migration/comparisonLogger', () => ({ + logMigrationComparison: vi.fn(), +})); + +vi.mock('@/libs/migration/migrationLogTransport', () => ({ + sendMigrationLog: vi.fn(), +})); + +const TEST_COUNTRY_ID = 'us' as const; +const TEST_V1_HOUSEHOLD_ID = '456'; +const TEST_V2_HOUSEHOLD_ID = '770e8400-e29b-41d4-a716-446655440002'; +const TEST_V1_USER_ID = 'anonymous'; +const TEST_V2_USER_ID = 'c93a763d-8d9f-4ab8-b04f-2fbba0183f35'; +const TEST_V1_ASSOC_ID = 'suh-abc123'; +const TEST_V2_ASSOC_ID = 'dd0e8400-e29b-41d4-a716-446655440008'; +const TEST_OLD_V1_HOUSEHOLD_ID = 'old-456'; +const TEST_OLD_V2_HOUSEHOLD_ID = '770e8400-e29b-41d4-a716-446655440099'; + +const v1HouseholdData = createMockHouseholdData({ + id: TEST_V1_HOUSEHOLD_ID, + countryId: TEST_COUNTRY_ID, + label: 'My household', +}); + +const v1Household = Household.fromAppInput({ + id: v1HouseholdData.id, + countryId: v1HouseholdData.countryId, + label: v1HouseholdData.label, + year: v1HouseholdData.year, + householdData: v1HouseholdData.data, +}); + +const v1Association: UserHouseholdPopulation = { + type: 'household', + id: TEST_V1_ASSOC_ID, + userId: TEST_V1_USER_ID, + householdId: TEST_V1_HOUSEHOLD_ID, + countryId: TEST_COUNTRY_ID, + label: 'My household', + createdAt: '2026-04-09T12:00:00Z', + isCreated: true, +}; + +describe('householdShadow', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('NEXT_PUBLIC_VERCEL_ENV', 'preview'); + localStorage.clear(); + setV2Id('User', TEST_V1_USER_ID, TEST_V2_USER_ID); + + vi.mocked(createHouseholdV2).mockResolvedValue( + createMockHouseholdV2Response({ + id: TEST_V2_HOUSEHOLD_ID, + country_id: TEST_COUNTRY_ID, + label: 'My household', + }) + ); + vi.mocked(createUserHouseholdAssociationV2).mockResolvedValue({ + id: TEST_V2_ASSOC_ID, + type: 'household', + userId: TEST_V2_USER_ID, + householdId: TEST_V2_HOUSEHOLD_ID, + countryId: TEST_COUNTRY_ID, + label: 'My household', + createdAt: '2026-04-09T12:00:01Z', + updatedAt: '2026-04-09T12:00:01Z', + isCreated: true, + }); + vi.mocked(updateUserHouseholdAssociationV2).mockResolvedValue({ + id: TEST_V2_ASSOC_ID, + type: 'household', + userId: TEST_V2_USER_ID, + householdId: TEST_V2_HOUSEHOLD_ID, + countryId: TEST_COUNTRY_ID, + label: 'My household', + createdAt: '2026-04-09T12:00:01Z', + updatedAt: '2026-04-09T12:00:02Z', + isCreated: true, + }); + vi.mocked(fetchUserHouseholdAssociationByIdV2).mockResolvedValue(null); + }); + + test('given successful v2 household create then it stores household and user-household mappings', async () => { + await shadowCreateHouseholdAndAssociation({ + v1HouseholdId: TEST_V1_HOUSEHOLD_ID, + v1Household, + v1Association, + }); + + expect(createHouseholdV2).toHaveBeenCalledWith( + expect.objectContaining({ + country_id: TEST_COUNTRY_ID, + year: 2026, + }) + ); + expect(createUserHouseholdAssociationV2).toHaveBeenCalledWith({ + userId: TEST_V2_USER_ID, + householdId: TEST_V2_HOUSEHOLD_ID, + countryId: TEST_COUNTRY_ID, + label: 'My household', + }); + expect(getV2Id('Household', TEST_V1_HOUSEHOLD_ID)).toBe(TEST_V2_HOUSEHOLD_ID); + expect(getV2Id('UserHousehold', TEST_V1_ASSOC_ID)).toBe(TEST_V2_ASSOC_ID); + expect(getV2AssociationTargetId('UserHousehold', TEST_V1_ASSOC_ID, TEST_V1_HOUSEHOLD_ID)).toBe( + TEST_V2_ASSOC_ID + ); + }); + + test('given successful shadow create then it logs household and user-household comparisons', async () => { + await shadowCreateHouseholdAndAssociation({ + v1HouseholdId: TEST_V1_HOUSEHOLD_ID, + v1Household, + v1Association, + }); + + expect(logMigrationComparison).toHaveBeenCalledWith( + 'HouseholdMigration', + 'CREATE', + expect.any(Object), + expect.any(Object), + { skipFields: ['id'] } + ); + expect(logMigrationComparison).toHaveBeenCalledWith( + 'UserHouseholdMigration', + 'CREATE', + expect.any(Object), + expect.any(Object), + { skipFields: ['id', 'createdAt', 'updatedAt', 'isCreated'] } + ); + }); + + test('given v2 household create fails then it logs and stays non-blocking', async () => { + vi.mocked(createHouseholdV2).mockRejectedValue(new Error('v2 unavailable')); + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + + await expect( + shadowCreateHouseholdAndAssociation({ + v1HouseholdId: TEST_V1_HOUSEHOLD_ID, + v1Household, + v1Association, + }) + ).resolves.toBeNull(); + + expect(createUserHouseholdAssociationV2).not.toHaveBeenCalled(); + expect(infoSpy).toHaveBeenCalledWith( + expect.stringContaining('[HouseholdMigration] Shadow v2 household create failed'), + expect.any(Error) + ); + expect(sendMigrationLog).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'event', + prefix: 'HouseholdMigration', + operation: 'CREATE', + status: 'FAILED', + }) + ); + }); + + test('given a multi-group household then it still shadow creates in v2', async () => { + const multiGroupHousehold = Household.fromDraft({ + countryId: TEST_COUNTRY_ID, + year: 2026, + householdData: { + people: { + adult: { age: { 2026: 35 } }, + child: { age: { 2026: 8 } }, + childTwo: { age: { 2026: 6 } }, + }, + maritalUnits: { + maritalUnit1: { members: ['adult'] }, + maritalUnit2: { members: ['child'] }, + maritalUnit3: { members: ['childTwo'] }, + }, + }, + label: 'Complex household', + id: TEST_V1_HOUSEHOLD_ID, + }); + + await expect( + shadowCreateHouseholdAndAssociation({ + v1HouseholdId: TEST_V1_HOUSEHOLD_ID, + v1Household: multiGroupHousehold, + v1Association, + }) + ).resolves.toBe(TEST_V2_HOUSEHOLD_ID); + + expect(createHouseholdV2).toHaveBeenCalledWith( + expect.objectContaining({ + marital_unit: [{ marital_unit_id: 0 }, { marital_unit_id: 1 }, { marital_unit_id: 2 }], + }) + ); + expect(createUserHouseholdAssociationV2).toHaveBeenCalledTimes(1); + }); + + test('given mapped ids then it updates the existing v2 user-household association', async () => { + setV2Id('Household', TEST_V1_HOUSEHOLD_ID, TEST_V2_HOUSEHOLD_ID); + setV2Id('UserHousehold', TEST_V1_ASSOC_ID, TEST_V2_ASSOC_ID); + + await shadowUpdateUserHouseholdAssociation(v1Association); + + expect(updateUserHouseholdAssociationV2).toHaveBeenCalledWith(TEST_V2_ASSOC_ID, { + label: 'My household', + householdId: TEST_V2_HOUSEHOLD_ID, + }); + expect(logMigrationComparison).toHaveBeenCalledWith( + 'UserHouseholdMigration', + 'UPDATE', + expect.any(Object), + expect.any(Object), + { skipFields: ['id', 'createdAt', 'updatedAt', 'isCreated'] } + ); + }); + + test('given missing association mapping but existing v2 association then it recovers the mapping and updates', async () => { + setV2Id('Household', TEST_V1_HOUSEHOLD_ID, TEST_V2_HOUSEHOLD_ID); + vi.mocked(fetchUserHouseholdAssociationByIdV2).mockResolvedValue({ + ...v1Association, + id: TEST_V2_ASSOC_ID, + userId: TEST_V2_USER_ID, + householdId: TEST_V2_HOUSEHOLD_ID, + }); + + await shadowUpdateUserHouseholdAssociation(v1Association); + + expect(fetchUserHouseholdAssociationByIdV2).toHaveBeenCalledWith( + TEST_V2_USER_ID, + TEST_V2_HOUSEHOLD_ID + ); + expect(getV2Id('UserHousehold', TEST_V1_ASSOC_ID)).toBe(TEST_V2_ASSOC_ID); + expect(updateUserHouseholdAssociationV2).toHaveBeenCalledWith(TEST_V2_ASSOC_ID, { + label: 'My household', + householdId: TEST_V2_HOUSEHOLD_ID, + }); + }); + + test('given missing association mapping but stored target mapping then it recovers without API lookup', async () => { + setV2Id('Household', TEST_V1_HOUSEHOLD_ID, TEST_V2_HOUSEHOLD_ID); + setV2AssociationTargetId( + 'UserHousehold', + TEST_V1_ASSOC_ID, + TEST_V1_HOUSEHOLD_ID, + TEST_V2_ASSOC_ID + ); + + await shadowUpdateUserHouseholdAssociation(v1Association); + + expect(fetchUserHouseholdAssociationByIdV2).not.toHaveBeenCalled(); + expect(getV2Id('UserHousehold', TEST_V1_ASSOC_ID)).toBe(TEST_V2_ASSOC_ID); + expect(updateUserHouseholdAssociationV2).toHaveBeenCalledWith(TEST_V2_ASSOC_ID, { + label: 'My household', + householdId: TEST_V2_HOUSEHOLD_ID, + }); + }); + + test('given missing association mapping and no existing v2 association then it recreates the v2 association', async () => { + setV2Id('Household', TEST_V1_HOUSEHOLD_ID, TEST_V2_HOUSEHOLD_ID); + + await shadowUpdateUserHouseholdAssociation(v1Association); + + expect(fetchUserHouseholdAssociationByIdV2).toHaveBeenCalledWith( + TEST_V2_USER_ID, + TEST_V2_HOUSEHOLD_ID + ); + expect(createUserHouseholdAssociationV2).toHaveBeenCalledWith({ + userId: TEST_V2_USER_ID, + householdId: TEST_V2_HOUSEHOLD_ID, + countryId: TEST_COUNTRY_ID, + label: 'My household', + }); + expect(updateUserHouseholdAssociationV2).not.toHaveBeenCalled(); + expect(getV2Id('UserHousehold', TEST_V1_ASSOC_ID)).toBe(TEST_V2_ASSOC_ID); + }); + + test('given immutable reassignment and missing association mapping then it recovers using the previous household mapping', async () => { + setV2Id('Household', TEST_OLD_V1_HOUSEHOLD_ID, TEST_OLD_V2_HOUSEHOLD_ID); + setV2Id('Household', TEST_V1_HOUSEHOLD_ID, TEST_V2_HOUSEHOLD_ID); + vi.mocked(fetchUserHouseholdAssociationByIdV2).mockResolvedValue({ + ...v1Association, + id: TEST_V2_ASSOC_ID, + userId: TEST_V2_USER_ID, + householdId: TEST_OLD_V2_HOUSEHOLD_ID, + }); + + await shadowUpdateUserHouseholdAssociation(v1Association, { + previousHouseholdId: TEST_OLD_V1_HOUSEHOLD_ID, + v2HouseholdId: TEST_V2_HOUSEHOLD_ID, + }); + + expect(fetchUserHouseholdAssociationByIdV2).toHaveBeenCalledWith( + TEST_V2_USER_ID, + TEST_OLD_V2_HOUSEHOLD_ID + ); + expect(updateUserHouseholdAssociationV2).toHaveBeenCalledWith(TEST_V2_ASSOC_ID, { + label: 'My household', + householdId: TEST_V2_HOUSEHOLD_ID, + }); + expect(createUserHouseholdAssociationV2).not.toHaveBeenCalled(); + }); + + test('given immutable reassignment and stored previous target mapping then it recovers without API lookup', async () => { + setV2Id('Household', TEST_OLD_V1_HOUSEHOLD_ID, TEST_OLD_V2_HOUSEHOLD_ID); + setV2Id('Household', TEST_V1_HOUSEHOLD_ID, TEST_V2_HOUSEHOLD_ID); + setV2AssociationTargetId( + 'UserHousehold', + TEST_V1_ASSOC_ID, + TEST_OLD_V1_HOUSEHOLD_ID, + TEST_V2_ASSOC_ID + ); + + await shadowUpdateUserHouseholdAssociation(v1Association, { + previousHouseholdId: TEST_OLD_V1_HOUSEHOLD_ID, + v2HouseholdId: TEST_V2_HOUSEHOLD_ID, + }); + + expect(fetchUserHouseholdAssociationByIdV2).not.toHaveBeenCalled(); + expect(updateUserHouseholdAssociationV2).toHaveBeenCalledWith(TEST_V2_ASSOC_ID, { + label: 'My household', + householdId: TEST_V2_HOUSEHOLD_ID, + }); + expect(getV2Id('UserHousehold', TEST_V1_ASSOC_ID)).toBe(TEST_V2_ASSOC_ID); + }); + + test('given missing household mapping then it logs a skipped update instead of silently returning', async () => { + await shadowUpdateUserHouseholdAssociation(v1Association); + + expect(fetchUserHouseholdAssociationByIdV2).not.toHaveBeenCalled(); + expect(updateUserHouseholdAssociationV2).not.toHaveBeenCalled(); + expect(sendMigrationLog).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'event', + prefix: 'UserHouseholdMigration', + operation: 'UPDATE', + status: 'SKIPPED', + message: 'Shadow v2 update skipped: missing mapped v2 household id', + }) + ); + }); +}); diff --git a/app/src/tests/unit/libs/migration/idMapping.test.ts b/app/src/tests/unit/libs/migration/idMapping.test.ts index f0e5a4127..162ee3bf3 100644 --- a/app/src/tests/unit/libs/migration/idMapping.test.ts +++ b/app/src/tests/unit/libs/migration/idMapping.test.ts @@ -1,10 +1,13 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { + clearV2AssociationTargetId, clearV2Mappings, getMappedV2UserId, getOrCreateV2UserId, + getV2AssociationTargetId, getV2Id, isUuid, + setV2AssociationTargetId, setV2Id, } from '@/libs/migration/idMapping'; @@ -48,27 +51,45 @@ describe('idMapping', () => { expect(localStorage.getItem('v1v2:policy:42')).toBe('uuid-policy'); expect(getV2Id('policy', '42')).toBe('uuid-policy'); }); + + test('given association target mapping then stores and retrieves by association and target ids', () => { + setV2AssociationTargetId('UserHousehold', 'suh-1', 'hh-1', 'uuid-association'); + + expect(getV2AssociationTargetId('UserHousehold', 'suh-1', 'hh-1')).toBe('uuid-association'); + expect(localStorage.getItem('v1v2-target:userhousehold:suh-1:hh-1')).toBe('uuid-association'); + }); + + test('given cleared association target mapping then returns null', () => { + setV2AssociationTargetId('UserHousehold', 'suh-1', 'hh-1', 'uuid-association'); + clearV2AssociationTargetId('UserHousehold', 'suh-1', 'hh-1'); + + expect(getV2AssociationTargetId('UserHousehold', 'suh-1', 'hh-1')).toBeNull(); + }); }); describe('clearV2Mappings', () => { test('given entity type then clears only that type', () => { setV2Id('Policy', 'sup-1', 'uuid-p1'); setV2Id('Household', 'sup-2', 'uuid-h1'); + setV2AssociationTargetId('Policy', 'sup-3', 'target-1', 'uuid-policy-association'); clearV2Mappings('Policy'); expect(getV2Id('Policy', 'sup-1')).toBeNull(); expect(getV2Id('Household', 'sup-2')).toBe('uuid-h1'); + expect(getV2AssociationTargetId('Policy', 'sup-3', 'target-1')).toBeNull(); }); test('given no entity type then clears all mappings', () => { setV2Id('Policy', 'sup-1', 'uuid-p1'); setV2Id('Household', 'sup-2', 'uuid-h1'); + setV2AssociationTargetId('UserHousehold', 'suh-1', 'hh-1', 'uuid-association'); clearV2Mappings(); expect(getV2Id('Policy', 'sup-1')).toBeNull(); expect(getV2Id('Household', 'sup-2')).toBeNull(); + expect(getV2AssociationTargetId('UserHousehold', 'suh-1', 'hh-1')).toBeNull(); }); test('given no mappings then does nothing', () => { diff --git a/app/src/tests/unit/models/Household.test.ts b/app/src/tests/unit/models/Household.test.ts index 08d16be2c..259e078f2 100644 --- a/app/src/tests/unit/models/Household.test.ts +++ b/app/src/tests/unit/models/Household.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { Household } from '@/models/Household'; +import { createMockUkHouseholdV2Response } from '@/tests/fixtures/api/v2/shared'; import { createMockEmptyHouseholdData, createMockHouseholdData, @@ -10,367 +11,802 @@ import { TEST_HOUSEHOLD_IDS, TEST_HOUSEHOLD_LABEL, } from '@/tests/fixtures/models/shared'; +import { + mockHouseholdMetadata, + mockHouseholdMetadataWithUnknownEntity, +} from '@/tests/fixtures/models/v1HouseholdMocks'; + +function createHousehold(overrides?: Parameters[0]): Household { + const data = createMockHouseholdData(overrides); + + return Household.fromAppInput({ + id: data.id, + countryId: data.countryId, + label: data.label, + year: data.year, + householdData: data.data, + }); +} + +function createEmptyHousehold( + overrides?: Parameters[0] +): Household { + const data = createMockEmptyHouseholdData(overrides); + + return Household.fromAppInput({ + id: data.id, + countryId: data.countryId, + label: data.label, + year: data.year, + householdData: data.data, + }); +} describe('Household', () => { - // ======================================================================== - // Constructor - // ======================================================================== + describe('constructor and accessors', () => { + it('stores the core household fields', () => { + const household = createHousehold(); - describe('constructor', () => { - it('given valid HouseholdData then sets id, countryId, label, and data', () => { - // Given - const data = createMockHouseholdData(); - - // When - const household = new Household(data); - - // Then expect(household.id).toBe(TEST_HOUSEHOLD_ID); expect(household.countryId).toBe(TEST_COUNTRY_ID); expect(household.label).toBe(TEST_HOUSEHOLD_LABEL); - expect(household.data).toBeDefined(); - expect(household.data.people).toBeDefined(); + expect(household.year).toBe(2026); + expect(household.householdData).toHaveProperty('taxUnits'); }); - it('given null label then label is null', () => { - // Given - const data = createMockHouseholdData({ label: null }); - - // When - const household = new Household(data); + it('returns people, person count, and names from the canonical household shape', () => { + const household = createHousehold(); - // Then - expect(household.label).toBeNull(); + expect(household.people).toEqual({ + adult: { age: { 2026: 35 }, employment_income: { 2026: 50000 } }, + child: { age: { 2026: 8 } }, + }); + expect(household.personCount).toBe(2); + expect(household.personNames).toEqual(['adult', 'child']); }); - }); - - // ======================================================================== - // label getter / setter - // ======================================================================== - describe('label', () => { - it('given label set via setter then getter returns new value', () => { - // Given - const household = new Household(createMockHouseholdData()); + it('handles households without people', () => { + const household = createEmptyHousehold(); - // When - household.label = 'Renamed household'; - - // Then - expect(household.label).toBe('Renamed household'); + expect(household.people).toEqual({}); + expect(household.personCount).toBe(0); + expect(household.personNames).toEqual([]); }); - it('given label set to null then getter returns null', () => { - // Given - const household = new Household(createMockHouseholdData()); - - // When - household.label = null; + it('creates immutable copies when changing ids or labels', () => { + const household = createHousehold(); - // Then - expect(household.label).toBeNull(); + expect(household.withId('new-id').id).toBe('new-id'); + expect(household.withLabel('Renamed household').label).toBe('Renamed household'); + expect(household.id).toBe(TEST_HOUSEHOLD_ID); + expect(household.label).toBe(TEST_HOUSEHOLD_LABEL); }); - }); - - // ======================================================================== - // data getter - // ======================================================================== - describe('data', () => { - it('given household with data then returns the data object', () => { - // Given - const inputData = createMockHouseholdData(); - - // When - const household = new Household(inputData); + it('preserves plural app household data when changing ids or labels', () => { + const household = createHousehold({ + data: { + people: { + adult: { age: { 2026: 35 } }, + child: { age: { 2026: 8 } }, + childTwo: { age: { 2026: 6 } }, + }, + maritalUnits: { + maritalUnit1: { members: ['adult'] }, + maritalUnit2: { members: ['child'] }, + maritalUnit3: { members: ['childTwo'] }, + }, + }, + }); - // Then - expect(household.data).toEqual(inputData.data); - expect(household.data).toHaveProperty('people'); - expect(household.data).toHaveProperty('tax_unit'); + expect(household.withId('new-id').householdData.maritalUnits).toEqual( + household.householdData.maritalUnits + ); + expect(household.withLabel('Renamed household').householdData.maritalUnits).toEqual( + household.householdData.maritalUnits + ); }); - it('given household with empty data then returns empty object', () => { - // Given - const inputData = createMockEmptyHouseholdData(); - - // When - const household = new Household(inputData); + it('preserves multiple groups of the same type in the app household shape', () => { + const household = createHousehold({ + data: { + people: { + adult: { age: { 2026: 35 } }, + child: { age: { 2026: 8 } }, + childTwo: { age: { 2026: 6 } }, + }, + maritalUnits: { + maritalUnit1: { members: ['adult'] }, + maritalUnit2: { members: ['child'] }, + maritalUnit3: { members: ['childTwo'] }, + }, + }, + }); - // Then - expect(household.data).toEqual({}); + expect(household.householdData.maritalUnits).toEqual({ + maritalUnit1: { members: ['adult'] }, + maritalUnit2: { members: ['child'] }, + maritalUnit3: { members: ['childTwo'] }, + }); }); - }); - - // ======================================================================== - // people getter - // ======================================================================== - describe('people', () => { - it('given household with people then extracts people from data', () => { - // Given - const household = new Household(createMockHouseholdData()); - - // When - const people = household.people; - - // Then - expect(people).toHaveProperty('adult'); - expect(people).toHaveProperty('child'); + it('rejects invalid secondary app-input groups instead of validating only the first one', () => { + expect(() => + Household.fromAppInput({ + countryId: 'us', + year: 2026, + householdData: { + people: { + adult: { age: { 2026: 35 } }, + }, + taxUnits: { + validTaxUnit: { members: ['adult'] }, + invalidTaxUnit: { members: ['missing-person'] }, + }, + }, + }) + ).toThrow('references unknown members: missing-person'); }); - it('given household with no people key then returns empty object', () => { - // Given - const household = new Household(createMockEmptyHouseholdData()); - - // When - const people = household.people; - - // Then - expect(people).toEqual({}); + it('rejects invalid app-input groups regardless of object insertion order', () => { + expect(() => + Household.fromAppInput({ + countryId: 'us', + year: 2026, + householdData: { + people: { + adult: { age: { 2026: 35 } }, + }, + taxUnits: { + invalidTaxUnit: { members: ['missing-person'] }, + validTaxUnit: { members: ['adult'] }, + }, + }, + }) + ).toThrow('references unknown members: missing-person'); }); - it('given household with people set to null then returns empty object', () => { - // Given - const household = new Household(createMockHouseholdData({ data: { people: null } })); - - // When - const people = household.people; - - // Then - expect(people).toEqual({}); + it('rejects malformed secondary app-input groups with non-array members', () => { + expect(() => + Household.fromAppInput({ + countryId: 'us', + year: 2026, + householdData: { + people: { + adult: { age: { 2026: 35 } }, + child: { age: { 2026: 8 } }, + }, + maritalUnits: { + validMaritalUnit: { members: ['adult'] }, + invalidMaritalUnit: { + members: 'child' as unknown as string[], + }, + }, + }, + }) + ).toThrow('invalidMaritalUnit.members must be an array'); }); }); - // ======================================================================== - // personCount - // ======================================================================== - - describe('personCount', () => { - it('given household with two people then returns 2', () => { - // Given - const household = new Household(createMockHouseholdData()); - - // When - const count = household.personCount; - - // Then - expect(count).toBe(2); + describe('fromV1Metadata', () => { + it('maps snake_case v1 household_json into the app household shape', () => { + const household = Household.fromV1Metadata(mockHouseholdMetadata); + + expect(household.id).toBe('12345'); + expect(household.countryId).toBe('us'); + expect(household.year).toBeGreaterThanOrEqual(2025); + expect(household.householdData).toEqual({ + people: mockHouseholdMetadata.household_json.people, + taxUnits: mockHouseholdMetadata.household_json.tax_units, + maritalUnits: mockHouseholdMetadata.household_json.marital_units, + spmUnits: mockHouseholdMetadata.household_json.spm_units, + households: mockHouseholdMetadata.household_json.households, + families: mockHouseholdMetadata.household_json.families, + }); }); - it('given household with no people then returns 0', () => { - // Given - const household = new Household(createMockEmptyHouseholdData()); - - // When - const count = household.personCount; - - // Then - expect(count).toBe(0); + it('rejects unknown entities instead of silently accepting them', () => { + expect(() => Household.fromV1Metadata(mockHouseholdMetadataWithUnknownEntity)).toThrow( + 'Unsupported household entities in v1 payload: unknown_entity' + ); }); - }); - - // ======================================================================== - // personNames - // ======================================================================== - - describe('personNames', () => { - it('given household with people then returns keys of people', () => { - // Given - const household = new Household(createMockHouseholdData()); - // When - const names = household.personNames; - - // Then - expect(names).toEqual(['adult', 'child']); + it('rejects v1 member groups that reference unknown people', () => { + expect(() => + Household.fromV1Metadata({ + ...mockHouseholdMetadata, + household_json: { + ...mockHouseholdMetadata.household_json, + tax_units: { + taxUnit1: { + members: ['person1', 'missing-person'], + }, + }, + }, + }) + ).toThrow('references unknown members: missing-person'); }); + }); - it('given household with no people then returns empty array', () => { - // Given - const household = new Household(createMockEmptyHouseholdData()); - - // When - const names = household.personNames; + describe('fromV1CreationPayload', () => { + it('builds a household model directly from a v1 create payload', () => { + const household = Household.fromV1CreationPayload( + { + country_id: 'us', + label: 'Created household', + data: mockHouseholdMetadata.household_json, + }, + { id: 'created-id' } + ); - // Then - expect(names).toEqual([]); + expect(household.id).toBe('created-id'); + expect(household.label).toBe('Created household'); + expect(household.householdData.people).toEqual(mockHouseholdMetadata.household_json.people); + expect(household.householdData.taxUnits).toEqual( + mockHouseholdMetadata.household_json.tax_units + ); }); }); - // ======================================================================== - // fromV2Response() - // ======================================================================== - describe('fromV2Response', () => { - it('given HouseholdV2Response then maps id and country_id correctly', () => { - // Given - const response = createMockHouseholdV2Response(); + it('maps a v2 response into the app household shape with year-wrapped values', () => { + const household = Household.fromV2Response(createMockHouseholdV2Response()); - // When - const household = Household.fromV2Response(response); - - // Then expect(household.id).toBe(TEST_HOUSEHOLD_IDS.HOUSEHOLD_A); expect(household.countryId).toBe(TEST_COUNTRY_ID); + expect(household.year).toBe(2026); + expect(household.label).toBe('My v2 household'); + expect(household.householdData).toEqual({ + people: { + adult: { + age: { 2026: 35 }, + employment_income: { 2026: 50000 }, + }, + child: { + age: { 2026: 8 }, + }, + }, + taxUnits: { + taxUnit1: { + members: ['adult', 'child'], + }, + }, + families: { + family1: { + members: ['adult', 'child'], + }, + }, + spmUnits: { + spmUnit1: { + members: ['adult', 'child'], + }, + }, + maritalUnits: { + maritalUnit1: { + members: ['adult'], + }, + }, + households: { + household1: { + members: ['adult', 'child'], + }, + }, + }); }); - it('given HouseholdV2Response with label then maps label', () => { - // Given - const response = createMockHouseholdV2Response({ - label: 'Custom household', + it('preserves multiple v2 group rows when reading a stored household response', () => { + const household = Household.fromV2Response( + createMockHouseholdV2Response({ + people: [ + { + name: 'adult', + person_id: 0, + person_tax_unit_id: 0, + person_marital_unit_id: 0, + person_household_id: 0, + age: 35, + }, + { + name: 'child', + person_id: 1, + person_tax_unit_id: 1, + person_marital_unit_id: 1, + person_household_id: 1, + age: 8, + }, + ], + tax_unit: [{ tax_unit_id: 0 }, { tax_unit_id: 1 }], + marital_unit: [{ marital_unit_id: 0 }, { marital_unit_id: 1 }], + household: [{ household_id: 0 }, { household_id: 1 }], + family: [], + spm_unit: [], + }) + ); + + expect(household.householdData.taxUnits).toEqual({ + taxUnit1: { members: ['adult'] }, + taxUnit2: { members: ['child'] }, + }); + expect(household.householdData.maritalUnits).toEqual({ + maritalUnit1: { members: ['adult'] }, + maritalUnit2: { members: ['child'] }, }); + expect(household.householdData.households).toEqual({ + household1: { members: ['adult'] }, + household2: { members: ['child'] }, + }); + }); - // When - const household = Household.fromV2Response(response); + it('handles a minimal v2 response with no entity groups', () => { + const household = Household.fromV2Response(createMockHouseholdV2ResponseMinimal()); - // Then - expect(household.label).toBe('Custom household'); + expect(household.householdData).toEqual({ + people: { + single_adult: { + age: { 2026: 30 }, + }, + }, + }); }); - it('given HouseholdV2Response with null label then label is null', () => { - // Given - const response = createMockHouseholdV2Response({ label: null }); - - // When - const household = Household.fromV2Response(response); + it('parses UK stored v2 households using only household and benunit groups', () => { + const household = Household.fromV2Response(createMockUkHouseholdV2Response()); - // Then - expect(household.label).toBeNull(); + expect(household.countryId).toBe('uk'); + expect(household.householdData).toEqual({ + people: { + adult: { + age: { 2026: 30 }, + employment_income: { 2026: 50000 }, + }, + }, + households: { + household1: { + members: ['adult'], + }, + }, + benunits: { + benunit1: { + members: ['adult'], + }, + }, + }); }); - it('given HouseholdV2Response then maps entity groups into data', () => { - // Given - const response = createMockHouseholdV2Response(); + it('rejects multiple v2 group rows that do not link back to people', () => { + expect(() => + Household.fromV2Response( + createMockHouseholdV2Response({ + people: [ + { + name: 'adult', + person_id: 0, + age: 35, + }, + ], + tax_unit: [{ tax_unit_id: 0 }, { tax_unit_id: 1 }], + family: [], + spm_unit: [], + marital_unit: [], + household: [], + }) + ) + ).toThrow( + 'V2 household tax_unit has multiple rows but people do not include person_tax_unit_id' + ); + }); - // When - const household = Household.fromV2Response(response); - const data = household.data; + it('treats unlinked stored v2 groups as single groups containing all people', () => { + const household = Household.fromV2Response( + createMockHouseholdV2Response({ + people: [ + { + name: 'adult', + age: 35, + }, + { + name: 'child', + age: 8, + }, + ], + tax_unit: [{}], + family: [{ filing_status: 'single' }], + spm_unit: [], + marital_unit: [], + household: [{ state_name: 'CA' }], + }) + ); - // Then - expect(data.people).toEqual(response.people); - expect(data.tax_unit).toEqual(response.tax_unit); - expect(data.family).toEqual(response.family); - expect(data.spm_unit).toEqual(response.spm_unit); - expect(data.marital_unit).toEqual(response.marital_unit); - expect(data.household).toEqual(response.household); - expect(data.benunit).toBeNull(); + expect(household.householdData.taxUnits).toEqual({ + taxUnit1: { + members: ['adult', 'child'], + }, + }); + expect(household.householdData.families).toEqual({ + family1: { + members: ['adult', 'child'], + filing_status: { 2026: 'single' }, + }, + }); + expect(household.householdData.households).toEqual({ + household1: { + members: ['adult', 'child'], + state_name: { 2026: 'CA' }, + }, + }); }); - it('given minimal HouseholdV2Response then maps null groups into data', () => { - // Given - const response = createMockHouseholdV2ResponseMinimal(); + it('still rejects missing group ids when person linkage is explicitly present', () => { + expect(() => + Household.fromV2Response( + createMockHouseholdV2Response({ + people: [ + { + name: 'adult', + person_id: 0, + person_tax_unit_id: 0, + age: 35, + }, + ], + tax_unit: [{}], + family: [], + spm_unit: [], + marital_unit: [], + household: [], + }) + ) + ).toThrow('V2 household tax_unit is missing numeric tax_unit_id'); + }); - // When - const household = Household.fromV2Response(response); - const data = household.data; + it('rejects duplicate explicit person names in v2 responses', () => { + expect(() => + Household.fromV2Response( + createMockHouseholdV2Response({ + people: [ + { + name: 'adult', + person_id: 0, + person_household_id: 0, + age: 35, + }, + { + name: 'adult', + person_id: 1, + person_household_id: 0, + age: 8, + }, + ], + household: [{ household_id: 0 }], + tax_unit: [], + family: [], + spm_unit: [], + marital_unit: [], + }) + ) + ).toThrow('Duplicate person name "adult" in v2 household response'); + }); + }); - // Then - expect(data.tax_unit).toBeNull(); - expect(data.family).toBeNull(); - expect(data.spm_unit).toBeNull(); - expect(data.marital_unit).toBeNull(); - expect(data.household).toBeNull(); - expect(data.benunit).toBeNull(); + describe('toV1CreationPayload', () => { + it('converts the app household shape back into the v1 creation payload shape', () => { + const household = createHousehold(); + + expect(household.toV1CreationPayload()).toEqual({ + country_id: 'us', + label: TEST_HOUSEHOLD_LABEL, + data: { + people: { + adult: { age: { 2026: 35 }, employment_income: { 2026: 50000 } }, + child: { age: { 2026: 8 } }, + }, + tax_units: { + taxUnit1: { members: ['adult', 'child'] }, + }, + families: { + family1: { members: ['adult', 'child'] }, + }, + spm_units: { + spmUnit1: { members: ['adult', 'child'] }, + }, + households: { + household1: { members: ['adult', 'child'] }, + }, + }, + }); }); - it('given HouseholdV2Response with country_id then casts to CountryId', () => { - // Given - const response = createMockHouseholdV2Response({ country_id: 'uk' }); + it('wraps scalar canonical values with the household year before emitting v1 payloads', () => { + const household = Household.fromAppInput({ + id: 'scalar-household', + countryId: 'us', + label: 'Scalar household', + year: 2026, + householdData: { + people: { + adult: { + age: 35, + }, + }, + }, + }); + + expect(household.toV1CreationPayload()).toEqual({ + country_id: 'us', + label: 'Scalar household', + data: { + people: { + adult: { + age: { 2026: 35 }, + }, + }, + }, + }); + }); - // When - const household = Household.fromV2Response(response); + it('preserves multi-group household data when emitting a v1 payload', () => { + const household = Household.fromDraft({ + countryId: 'us', + year: 2026, + householdData: { + people: { + adult: { age: { 2026: 35 } }, + child: { age: { 2026: 8 } }, + childTwo: { age: { 2026: 6 } }, + }, + maritalUnits: { + maritalUnit1: { members: ['adult'] }, + maritalUnit2: { members: ['child'] }, + maritalUnit3: { members: ['childTwo'] }, + }, + }, + }); - // Then - expect(household.countryId).toBe('uk'); + expect(household.toV1CreationPayload()).toEqual({ + country_id: 'us', + label: undefined, + data: { + people: { + adult: { age: { 2026: 35 } }, + child: { age: { 2026: 8 } }, + childTwo: { age: { 2026: 6 } }, + }, + marital_units: { + maritalUnit1: { members: ['adult'] }, + maritalUnit2: { members: ['child'] }, + maritalUnit3: { members: ['childTwo'] }, + }, + }, + }); }); }); - // ======================================================================== - // toJSON() roundtrip - // ======================================================================== - - describe('toJSON', () => { - it('given household created from data then toJSON deep equals original data', () => { - // Given - const data = createMockHouseholdData(); + describe('toV2CreateEnvelope', () => { + it('flattens year-keyed values and attaches relationship ids for the v2 create envelope', () => { + const household = createHousehold(); + + expect(household.toV2CreateEnvelope()).toEqual({ + country_id: 'us', + year: 2026, + label: TEST_HOUSEHOLD_LABEL, + people: [ + { + name: 'adult', + person_id: 0, + person_household_id: 0, + person_family_id: 0, + person_spm_unit_id: 0, + person_tax_unit_id: 0, + age: 35, + employment_income: 50000, + }, + { + name: 'child', + person_id: 1, + person_household_id: 0, + person_family_id: 0, + person_spm_unit_id: 0, + person_tax_unit_id: 0, + age: 8, + }, + ], + household: [{ household_id: 0 }], + family: [{ family_id: 0 }], + spm_unit: [{ spm_unit_id: 0 }], + tax_unit: [{ tax_unit_id: 0 }], + marital_unit: [], + }); + }); - // When - const household = new Household(data); - const json = household.toJSON(); + it('infers the year for draft households created without an explicit year', () => { + const household = Household.fromDraft({ + countryId: 'us', + householdData: createMockHouseholdData().data, + }); - // Then - expect(json).toEqual(data); + expect(household.toV2CreateEnvelope().year).toBe(2026); }); - it('given household with updated label then toJSON reflects the update', () => { - // Given - const data = createMockHouseholdData(); - const household = new Household(data); + it('builds a v2 create envelope without the persisted id', () => { + const household = createHousehold(); + + expect(household.toV2CreateEnvelope()).toEqual({ + country_id: 'us', + year: 2026, + label: TEST_HOUSEHOLD_LABEL, + people: [ + { + name: 'adult', + person_id: 0, + person_household_id: 0, + person_family_id: 0, + person_spm_unit_id: 0, + person_tax_unit_id: 0, + age: 35, + employment_income: 50000, + }, + { + name: 'child', + person_id: 1, + person_household_id: 0, + person_family_id: 0, + person_spm_unit_id: 0, + person_tax_unit_id: 0, + age: 8, + }, + ], + household: [{ household_id: 0 }], + family: [{ family_id: 0 }], + spm_unit: [{ spm_unit_id: 0 }], + tax_unit: [{ tax_unit_id: 0 }], + marital_unit: [], + }); + }); - // When - household.label = 'Updated household'; - const json = household.toJSON(); + it('emits only UK household and benunit groups for UK v2 envelopes', () => { + const household = Household.fromDraft({ + countryId: 'uk', + year: 2026, + householdData: { + people: { + adult: { age: { 2026: 35 }, employment_income: { 2026: 50000 } }, + }, + households: { + household1: { members: ['adult'] }, + }, + benunits: { + benunit1: { members: ['adult'] }, + }, + }, + }); - // Then - expect(json.label).toBe('Updated household'); - expect(json.id).toBe(TEST_HOUSEHOLD_ID); + expect(household.toV2CreateEnvelope()).toEqual({ + country_id: 'uk', + year: 2026, + label: null, + people: [ + { + name: 'adult', + person_id: 0, + person_household_id: 0, + person_benunit_id: 0, + age: 35, + employment_income: 50000, + }, + ], + household: [{ household_id: 0 }], + benunit: [{ benunit_id: 0 }], + }); }); - it('given empty household then toJSON roundtrips correctly', () => { - // Given - const data = createMockEmptyHouseholdData(); - - // When - const household = new Household(data); - const json = household.toJSON(); + it('preserves multiple stored v2 groups of the same type', () => { + const household = Household.fromDraft({ + countryId: 'us', + year: 2026, + householdData: { + people: { + adult: { age: { 2026: 35 } }, + child: { age: { 2026: 8 } }, + childTwo: { age: { 2026: 6 } }, + }, + maritalUnits: { + maritalUnit1: { members: ['adult'] }, + maritalUnit2: { members: ['child'] }, + maritalUnit3: { members: ['childTwo'] }, + }, + }, + }); - // Then - expect(json).toEqual(data); - expect(json.data).toEqual({}); + const envelope = household.toV2CreateEnvelope(); + + expect(envelope.country_id).toBe('us'); + if (envelope.country_id !== 'us') { + throw new Error('Expected US v2 envelope'); + } + expect(envelope.marital_unit).toEqual([ + { marital_unit_id: 0 }, + { marital_unit_id: 1 }, + { marital_unit_id: 2 }, + ]); }); }); - // ======================================================================== - // isEqual - // ======================================================================== - - describe('isEqual', () => { - it('given same id then returns true', () => { - // Given - const householdA = new Household(createMockHouseholdData()); - const householdB = new Household(createMockHouseholdData()); - - // When / Then - expect(householdA.isEqual(householdB)).toBe(true); + describe('toComparable', () => { + it('returns a stable v2-shaped representation for household comparison', () => { + const household = createHousehold(); + + expect(household.toComparable()).toEqual({ + id: TEST_HOUSEHOLD_ID, + countryId: 'us', + year: 2026, + label: TEST_HOUSEHOLD_LABEL, + data: { + family: [{ family_id: 0 }], + household: [{ household_id: 0 }], + marital_unit: [], + people: [ + { + age: 35, + employment_income: 50000, + name: 'adult', + person_family_id: 0, + person_household_id: 0, + person_id: 0, + person_spm_unit_id: 0, + person_tax_unit_id: 0, + }, + { + age: 8, + name: 'child', + person_family_id: 0, + person_household_id: 0, + person_id: 1, + person_spm_unit_id: 0, + person_tax_unit_id: 0, + }, + ], + spm_unit: [{ spm_unit_id: 0 }], + tax_unit: [{ tax_unit_id: 0 }], + }, + }); }); + }); - it('given different id then returns false', () => { - // Given - const householdA = new Household(createMockHouseholdData()); - const householdB = new Household(createMockHouseholdData({ id: 'different-id-999' })); + describe('serialization and equality', () => { + it('round-trips through toJSON()', () => { + const data = createMockHouseholdData(); + const household = Household.fromAppInput({ + id: data.id, + countryId: data.countryId, + label: data.label, + year: data.year, + householdData: data.data, + }); - // When / Then - expect(householdA.isEqual(householdB)).toBe(false); + expect(household.toJSON()).toEqual({ + id: data.id, + countryId: data.countryId, + label: data.label, + year: data.year, + householdData: data.data, + }); }); - it('given same id but different label then returns false', () => { - // Given - const householdA = new Household(createMockHouseholdData()); - const householdB = new Household(createMockHouseholdData({ label: 'Different label' })); + it('treats identical households as equal', () => { + const householdA = createHousehold(); + const householdB = createHousehold(); - // When / Then - expect(householdA.isEqual(householdB)).toBe(false); + expect(householdA.isEqual(householdB)).toBe(true); }); - it('given same id but different data then returns false', () => { - // Given - const householdA = new Household(createMockHouseholdData()); - const householdB = new Household( - createMockHouseholdData({ data: { people: { solo: { age: 99 } } } }) - ); + it('detects a change in household data', () => { + const householdA = createHousehold(); + const householdB = createHousehold({ + data: { + people: { + solo: { age: { 2026: 99 } }, + }, + }, + }); - // When / Then expect(householdA.isEqual(householdB)).toBe(false); }); }); diff --git a/app/src/tests/unit/pages/Populations.page.test.tsx b/app/src/tests/unit/pages/Populations.page.test.tsx index 94f674488..cae069794 100644 --- a/app/src/tests/unit/pages/Populations.page.test.tsx +++ b/app/src/tests/unit/pages/Populations.page.test.tsx @@ -7,8 +7,8 @@ import { createEmptyDataState, createErrorState, createLoadingState, - mockGeographicAssociationsData, - mockUserHouseholdsData, + createMockGeographicAssociationsData, + createMockUserHouseholdsData, POPULATION_COLUMNS, POPULATION_LABELS, POPULATION_TEST_IDS, @@ -56,21 +56,25 @@ vi.mock('@/constants', () => ({ describe('PopulationsPage', () => { let consoleMocks: ReturnType; + let userHouseholdsData: ReturnType; + let geographicAssociationsData: ReturnType; beforeEach(() => { vi.clearAllMocks(); consoleMocks = setupMockConsole(); + userHouseholdsData = createMockUserHouseholdsData(); + geographicAssociationsData = createMockGeographicAssociationsData(); // Set default mock implementations (useUserHouseholds as any).mockReturnValue({ - data: mockUserHouseholdsData, + data: userHouseholdsData, isLoading: false, isError: false, error: null, }); (useGeographicAssociationsByUser as any).mockReturnValue({ - data: mockGeographicAssociationsData, + data: geographicAssociationsData, isLoading: false, isError: false, error: null, @@ -328,9 +332,9 @@ describe('PopulationsPage', () => { // Given const dataWithoutLabel = [ { - ...mockUserHouseholdsData[0], + ...userHouseholdsData[0], association: { - ...mockUserHouseholdsData[0].association, + ...userHouseholdsData[0].association, label: undefined, }, }, @@ -356,9 +360,9 @@ describe('PopulationsPage', () => { // Given const dataWithoutDate = [ { - ...mockUserHouseholdsData[0], + ...userHouseholdsData[0], association: { - ...mockUserHouseholdsData[0].association, + ...userHouseholdsData[0].association, createdAt: undefined, }, }, @@ -376,7 +380,7 @@ describe('PopulationsPage', () => { // Then - Check that the household data is displayed (but without checking for specific date text) expect( - screen.getByText(mockUserHouseholdsData[0].association.label || 'Household') + screen.getByText(userHouseholdsData[0].association.label || 'Household') ).toBeInTheDocument(); }); @@ -384,10 +388,10 @@ describe('PopulationsPage', () => { // Given const dataWithNoPeople = [ { - ...mockUserHouseholdsData[0], + ...userHouseholdsData[0], household: { - ...mockUserHouseholdsData[0].household, - household_json: { + ...userHouseholdsData[0].household, + householdData: { people: {}, families: {}, }, diff --git a/app/src/tests/unit/pathways/report/views/population/PopulationExistingView.test.tsx b/app/src/tests/unit/pathways/report/views/population/PopulationExistingView.test.tsx new file mode 100644 index 000000000..413b3b0f6 --- /dev/null +++ b/app/src/tests/unit/pathways/report/views/population/PopulationExistingView.test.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { configureStore } from '@reduxjs/toolkit'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { UserHouseholdMetadataWithAssociation } from '@/hooks/useUserHousehold'; +import { Household as HouseholdModel } from '@/models/Household'; +import PopulationExistingView from '@/pathways/report/views/population/PopulationExistingView'; + +const mockUseUserHouseholds = vi.fn(); +const mockUseUserGeographics = vi.fn(); + +vi.mock('@/constants', () => ({ + MOCK_USER_ID: 'test-user-123', +})); + +vi.mock('@/hooks/useUserHousehold', () => ({ + useUserHouseholds: () => mockUseUserHouseholds(), + isHouseholdMetadataWithAssociation: (value: unknown) => + !!value && + typeof value === 'object' && + value !== null && + 'association' in value && + 'household' in value, +})); + +vi.mock('@/hooks/useUserGeographic', () => ({ + useUserGeographics: () => mockUseUserGeographics(), + isGeographicMetadataWithAssociation: () => false, +})); + +vi.mock('@/utils/validation/ingredientValidation', () => ({ + isHouseholdAssociationReady: () => true, + isGeographicAssociationReady: () => false, +})); + +vi.mock('@/components/common/PathwayView', () => ({ + default: ({ + title, + cardListItems = [], + primaryAction, + }: { + title: string; + cardListItems?: Array<{ + id: string; + title: string; + subtitle?: string; + onClick: () => void; + }>; + primaryAction?: { + label: string; + onClick: () => void; + isDisabled?: boolean; + }; + }) => ( +
+

{title}

+ {cardListItems.map((item) => ( + + ))} + {primaryAction ? ( + + ) : null} +
+ ), +})); + +const selectedHousehold = HouseholdModel.fromDraft({ + id: 'household-123', + countryId: 'us', + householdData: { + people: { + you: { + age: { 2026: 40 }, + }, + }, + households: { + household1: { + members: ['you'], + }, + }, + }, + label: 'Selected household', +}); + +const mockHouseholdAssociation: UserHouseholdMetadataWithAssociation = { + association: { + id: 'user-household-123', + type: 'household', + userId: 'test-user-123', + householdId: 'household-123', + countryId: 'us', + label: 'Selected household', + createdAt: '2026-04-10T12:00:00Z', + }, + household: selectedHousehold, + isLoading: false, + error: null, + isError: false, +}; + +function createStore() { + return configureStore({ + reducer: { + metadata: () => ({ + currentCountry: 'us', + economyOptions: { region: [] }, + }), + }, + }); +} + +function renderView(props?: Partial>) { + return render( + + + + ); +} + +describe('PopulationExistingView', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseUserHouseholds.mockReturnValue({ + data: [mockHouseholdAssociation], + isLoading: false, + isError: false, + error: null, + }); + mockUseUserGeographics.mockReturnValue({ + data: [], + isLoading: false, + isError: false, + error: null, + }); + }); + + test('given a saved household selection then it passes the canonical household model to onSelectHousehold', async () => { + const user = userEvent.setup(); + const onSelectHousehold = vi.fn(); + + renderView({ onSelectHousehold }); + + await user.click(screen.getByRole('button', { name: 'Selected household' })); + await user.click(screen.getByRole('button', { name: 'Next' })); + + expect(onSelectHousehold).toHaveBeenCalledWith( + 'household-123', + selectedHousehold, + 'Selected household' + ); + }); +}); diff --git a/app/src/tests/unit/utils/HouseholdBuilder.test.ts b/app/src/tests/unit/utils/HouseholdBuilder.test.ts index f54e5a41f..782c6fff6 100644 --- a/app/src/tests/unit/utils/HouseholdBuilder.test.ts +++ b/app/src/tests/unit/utils/HouseholdBuilder.test.ts @@ -22,6 +22,7 @@ import { YEARS, } from '@/tests/fixtures/utils/householdBuilderMocks'; import { HouseholdBuilder } from '@/utils/HouseholdBuilder'; +import { getHouseholdYearValue } from '@/utils/householdDataAccess'; describe('HouseholdBuilder', () => { let builder: HouseholdBuilder; @@ -91,7 +92,7 @@ describe('HouseholdBuilder', () => { // Then const person = household.householdData.people[PERSON_NAMES.ADULT_1]; - expect(person.age[DEFAULT_YEAR]).toBe(PERSON_AGES.ADULT_DEFAULT); + expect(getHouseholdYearValue(person.age, DEFAULT_YEAR)).toBe(PERSON_AGES.ADULT_DEFAULT); }); test('given invalid year format when constructed then throws error', () => { @@ -116,13 +117,18 @@ describe('HouseholdBuilder', () => { const household1 = builder.build(); // When - household1.householdData.people[PERSON_NAMES.ADULT_1].age[YEARS.CURRENT] = 99; + (household1.householdData.people[PERSON_NAMES.ADULT_1].age as Record)[ + YEARS.CURRENT + ] = 99; const household2 = builder.build(); // Then - expect(household2.householdData.people[PERSON_NAMES.ADULT_1].age[YEARS.CURRENT]).toBe( - PERSON_AGES.ADULT_DEFAULT - ); + expect( + getHouseholdYearValue( + household2.householdData.people[PERSON_NAMES.ADULT_1].age, + YEARS.CURRENT + ) + ).toBe(PERSON_AGES.ADULT_DEFAULT); }); test('given multiple builds when called then each returns independent copy', () => { @@ -240,7 +246,7 @@ describe('HouseholdBuilder', () => { // Then const person = household.householdData.people[PERSON_NAMES.ADULT_1]; - expect(person[VARIABLE_NAMES.EMPLOYMENT_INCOME][YEARS.PAST]).toBe( + expect(getHouseholdYearValue(person[VARIABLE_NAMES.EMPLOYMENT_INCOME], YEARS.PAST)).toBe( VARIABLE_VALUES.INCOME_DEFAULT ); }); @@ -301,6 +307,28 @@ describe('HouseholdBuilder', () => { ).toBe(3); }); + test('given US child when addChild then creates a child marital unit', () => { + // Given + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + builder.addAdult(PERSON_NAMES.ADULT_2, PERSON_AGES.ADULT_DEFAULT); + + // When + builder.addChild(PERSON_NAMES.CHILD_1, PERSON_AGES.CHILD_DEFAULT, []); + const household = builder.build(); + + // Then + const maritalUnits = household.householdData.maritalUnits!; + expect(Object.keys(maritalUnits)).toHaveLength(2); + expect(Object.keys(maritalUnits)).toContain(`${PERSON_NAMES.CHILD_1}'s marital unit`); + expect(maritalUnits[GROUP_KEYS.DEFAULT_MARITAL_UNIT].members).toEqual([ + PERSON_NAMES.ADULT_1, + PERSON_NAMES.ADULT_2, + ]); + expect(maritalUnits[`${PERSON_NAMES.CHILD_1}'s marital unit`].members).toEqual([ + PERSON_NAMES.CHILD_1, + ]); + }); + test('given child with variables when addChild then includes variables', () => { // Given const variables = { [VARIABLE_NAMES.CUSTOM_VAR]: VARIABLE_VALUES.STRING_VALUE }; @@ -471,7 +499,7 @@ describe('HouseholdBuilder', () => { // Then const person = household.householdData.people[PERSON_NAMES.ADULT_1]; - expect(person[VARIABLE_NAMES.EMPLOYMENT_INCOME][YEARS.FUTURE]).toBe( + expect(getHouseholdYearValue(person[VARIABLE_NAMES.EMPLOYMENT_INCOME], YEARS.FUTURE)).toBe( VARIABLE_VALUES.INCOME_HIGH ); }); @@ -555,7 +583,9 @@ describe('HouseholdBuilder', () => { // Then const group = household.householdData.households![GROUP_KEYS.DEFAULT_HOUSEHOLD]; - expect(group[VARIABLE_NAMES.STATE_CODE][YEARS.PAST]).toBe(VARIABLE_VALUES.STATE_NY); + expect(getHouseholdYearValue(group[VARIABLE_NAMES.STATE_CODE], YEARS.PAST)).toBe( + VARIABLE_VALUES.STATE_NY + ); }); }); @@ -704,8 +734,8 @@ describe('HouseholdBuilder', () => { // Then const person1 = household.householdData.people[PERSON_NAMES.ADULT_1]; const person2 = household.householdData.people[PERSON_NAMES.ADULT_2]; - expect(person1.age[YEARS.CURRENT]).toBe(PERSON_AGES.ADULT_DEFAULT); - expect(person2.age[YEARS.FUTURE]).toBe(PERSON_AGES.ADULT_DEFAULT); + expect(getHouseholdYearValue(person1.age, YEARS.CURRENT)).toBe(PERSON_AGES.ADULT_DEFAULT); + expect(getHouseholdYearValue(person2.age, YEARS.FUTURE)).toBe(PERSON_AGES.ADULT_DEFAULT); }); test('given invalid year when setCurrentYear then throws error', () => { @@ -733,10 +763,12 @@ describe('HouseholdBuilder', () => { // Then const person = household.householdData.people[PERSON_NAMES.ADULT_1]; - expect(person[VARIABLE_NAMES.EMPLOYMENT_INCOME][YEARS.PAST]).toBe( + expect(getHouseholdYearValue(person[VARIABLE_NAMES.EMPLOYMENT_INCOME], YEARS.PAST)).toBe( VARIABLE_VALUES.INCOME_DEFAULT ); - expect(person[VARIABLE_NAMES.EMPLOYMENT_INCOME][YEARS.CURRENT]).toBeUndefined(); + expect( + getHouseholdYearValue(person[VARIABLE_NAMES.EMPLOYMENT_INCOME], YEARS.CURRENT) + ).toBeUndefined(); }); }); @@ -794,11 +826,18 @@ describe('HouseholdBuilder', () => { // When const household = builder.getHousehold(); - household.householdData.people[PERSON_NAMES.ADULT_1].age[YEARS.CURRENT] = 99; + (household.householdData.people[PERSON_NAMES.ADULT_1].age as Record)[ + YEARS.CURRENT + ] = 99; const builtHousehold = builder.build(); // Then - expect(builtHousehold.householdData.people[PERSON_NAMES.ADULT_1].age[YEARS.CURRENT]).toBe(99); + expect( + getHouseholdYearValue( + builtHousehold.householdData.people[PERSON_NAMES.ADULT_1].age, + YEARS.CURRENT + ) + ).toBe(99); }); }); diff --git a/app/src/tests/unit/utils/HouseholdQueries.test.ts b/app/src/tests/unit/utils/HouseholdQueries.test.ts index 166a5c61d..0553e95af 100644 --- a/app/src/tests/unit/utils/HouseholdQueries.test.ts +++ b/app/src/tests/unit/utils/HouseholdQueries.test.ts @@ -21,6 +21,7 @@ import { verifyPeopleArray, verifyPersonWithName, } from '@/tests/fixtures/utils/householdQueriesMocks'; +import { getHouseholdYearValue } from '@/utils/householdDataAccess'; import { getAdultCount, getAdults, @@ -61,10 +62,10 @@ describe('HouseholdQueries', () => { // Verify structure const adult1 = result.find((p) => p.name === QUERY_PERSON_NAMES.ADULT_1); expect(adult1).toBeDefined(); - expect(adult1!.age[QUERY_YEARS.CURRENT]).toBe(QUERY_AGES.ADULT_30); - expect(adult1![QUERY_VARIABLE_NAMES.EMPLOYMENT_INCOME][QUERY_YEARS.CURRENT]).toBe( - QUERY_VARIABLE_VALUES.INCOME_50K - ); + expect(getHouseholdYearValue(adult1!.age, QUERY_YEARS.CURRENT)).toBe(QUERY_AGES.ADULT_30); + expect( + getHouseholdYearValue(adult1![QUERY_VARIABLE_NAMES.EMPLOYMENT_INCOME], QUERY_YEARS.CURRENT) + ).toBe(QUERY_VARIABLE_VALUES.INCOME_50K); }); test('given single person household when getting all then returns one person', () => { diff --git a/app/src/tests/unit/utils/VariableResolver.test.ts b/app/src/tests/unit/utils/VariableResolver.test.ts new file mode 100644 index 000000000..0bb853489 --- /dev/null +++ b/app/src/tests/unit/utils/VariableResolver.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; +import type { AppHouseholdInputEnvelope } from '@/models/household/appTypes'; +import { getValue, removeVariableFromEntity, setValue } from '@/utils/VariableResolver'; + +const TEST_METADATA = { + variables: { + state_name: { + name: 'state_name', + label: 'State name', + entity: 'household', + valueType: 'str', + defaultValue: '', + isInputVariable: true, + hidden_input: false, + moduleName: 'household', + }, + taxable_income: { + name: 'taxable_income', + label: 'Taxable income', + entity: 'tax_unit', + valueType: 'float', + defaultValue: 0, + isInputVariable: true, + hidden_input: false, + moduleName: 'tax_unit', + }, + employment_income: { + name: 'employment_income', + label: 'Employment income', + entity: 'person', + valueType: 'float', + defaultValue: 0, + isInputVariable: true, + hidden_input: false, + moduleName: 'person', + }, + }, + entities: { + person: { + plural: 'people', + label: 'Person', + is_person: true, + }, + household: { + plural: 'households', + label: 'Household', + is_person: false, + }, + tax_unit: { + plural: 'tax_units', + label: 'Tax Unit', + is_person: false, + }, + }, +}; + +function createNonDefaultGroupHousehold(): AppHouseholdInputEnvelope { + return { + id: 'household-1', + countryId: 'us', + householdData: { + people: { + adult: { age: { '2026': 35 } }, + }, + households: { + household2: { + members: ['adult'], + state_name: { '2026': 'ca' }, + }, + }, + taxUnits: { + taxUnit2: { + members: ['adult'], + taxable_income: { '2026': 50000 }, + }, + }, + }, + }; +} + +function createHouseholdWithPersonVariable(): AppHouseholdInputEnvelope { + return { + id: 'household-1', + countryId: 'us', + householdData: { + people: { + adult: { + age: { '2026': 35 }, + employment_income: { '2026': 50000 }, + }, + child: { + age: { '2026': 8 }, + employment_income: { '2026': 0 }, + }, + }, + }, + }; +} + +describe('VariableResolver', () => { + it('reads household-level values from the first available real group name', () => { + const household = createNonDefaultGroupHousehold(); + + expect(getValue(household, 'state_name', TEST_METADATA, '2026')).toBe('ca'); + expect(getValue(household, 'taxable_income', TEST_METADATA, '2026')).toBe(50000); + }); + + it('writes household-level values to the first available real group name', () => { + const household = createNonDefaultGroupHousehold(); + + const updatedHousehold = setValue(household, 'state_name', 'ny', TEST_METADATA, '2026'); + const updatedTaxUnitHousehold = setValue( + household, + 'taxable_income', + 75000, + TEST_METADATA, + '2026' + ); + + expect(updatedHousehold.householdData.households?.household2?.state_name).toEqual({ + '2026': 'ny', + }); + expect(updatedTaxUnitHousehold.householdData.taxUnits?.taxUnit2?.taxable_income).toEqual({ + '2026': 75000, + }); + expect(household.householdData.households?.household2?.state_name).toEqual({ + '2026': 'ca', + }); + expect(household.householdData.taxUnits?.taxUnit2?.taxable_income).toEqual({ + '2026': 50000, + }); + }); + + it('removes a person-level variable immutably from only the targeted person', () => { + const household = createHouseholdWithPersonVariable(); + + const updatedHousehold = removeVariableFromEntity( + household, + 'employment_income', + TEST_METADATA, + 'adult' + ); + + expect(updatedHousehold.householdData.people.adult.employment_income).toBeUndefined(); + expect(updatedHousehold.householdData.people.child.employment_income).toEqual({ + '2026': 0, + }); + expect(household.householdData.people.adult.employment_income).toEqual({ + '2026': 50000, + }); + expect(household.householdData.people.child.employment_income).toEqual({ + '2026': 0, + }); + }); +}); diff --git a/app/src/tests/unit/utils/householdDataAccess.test.ts b/app/src/tests/unit/utils/householdDataAccess.test.ts new file mode 100644 index 000000000..014b14718 --- /dev/null +++ b/app/src/tests/unit/utils/householdDataAccess.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import type { AppHouseholdInputData } from '@/models/household/appTypes'; +import { + ensureHouseholdGroupInstance, + getPreferredHouseholdGroupName, +} from '@/utils/householdDataAccess'; + +function createHouseholdData(): AppHouseholdInputData { + return { + people: { + adult: { age: { '2026': 35 } }, + child: { age: { '2026': 8 } }, + }, + }; +} + +describe('householdDataAccess', () => { + it('creates an entity-specific default group instead of using legacy household naming', () => { + const householdData = createHouseholdData(); + + const taxUnitName = ensureHouseholdGroupInstance(householdData, 'tax_units'); + const maritalUnitName = ensureHouseholdGroupInstance(householdData, 'maritalUnits'); + + expect(taxUnitName).toBe('taxUnit1'); + expect(maritalUnitName).toBe('maritalUnit1'); + expect(householdData.taxUnits?.taxUnit1).toEqual({ + members: ['adult', 'child'], + }); + expect(householdData.maritalUnits?.maritalUnit1).toEqual({ + members: ['adult', 'child'], + }); + }); + + it('reuses the first existing group name when one is already present', () => { + const householdData = createHouseholdData(); + householdData.households = { + household2: { + members: ['adult', 'child'], + }, + }; + + expect(ensureHouseholdGroupInstance(householdData, 'households')).toBe('household2'); + expect(getPreferredHouseholdGroupName(householdData, 'households')).toBe('household2'); + }); +}); diff --git a/app/src/tests/unit/utils/householdHead.test.ts b/app/src/tests/unit/utils/householdHead.test.ts index a0b1ba2cc..6c37e37e4 100644 --- a/app/src/tests/unit/utils/householdHead.test.ts +++ b/app/src/tests/unit/utils/householdHead.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from 'vitest'; -import type { Household } from '@/types/ingredients/Household'; +import type { AppHouseholdInputEnvelope } from '@/models/household/appTypes'; import { getHeadOfHouseholdPersonName } from '@/utils/householdHead'; const YEAR = '2026'; describe('getHeadOfHouseholdPersonName', () => { test('prefers the canonical "you" person when present', () => { - const household: Household = { + const household: AppHouseholdInputEnvelope = { countryId: 'us', householdData: { people: { @@ -17,8 +17,8 @@ describe('getHeadOfHouseholdPersonName', () => { age: { [YEAR]: 36 }, }, }, - tax_units: { - 'your tax unit': { + taxUnits: { + taxUnit1: { members: ['spouse', 'you'], }, }, @@ -29,7 +29,7 @@ describe('getHeadOfHouseholdPersonName', () => { }); test('falls back to the first adult member of the first tax unit when flags are absent', () => { - const household: Household = { + const household: AppHouseholdInputEnvelope = { countryId: 'us', householdData: { people: { @@ -40,8 +40,8 @@ describe('getHeadOfHouseholdPersonName', () => { age: { [YEAR]: 40 }, }, }, - tax_units: { - 'your tax unit': { + taxUnits: { + taxUnit1: { members: ['child', 'adult'], }, }, @@ -52,7 +52,7 @@ describe('getHeadOfHouseholdPersonName', () => { }); test('falls back to you when no explicit group structure exists', () => { - const household: Household = { + const household: AppHouseholdInputEnvelope = { countryId: 'us', householdData: { people: { @@ -70,7 +70,7 @@ describe('getHeadOfHouseholdPersonName', () => { }); test('uses stable sorted fallback when no other signal exists', () => { - const household: Household = { + const household: AppHouseholdInputEnvelope = { countryId: 'uk', householdData: { people: { diff --git a/app/src/types/calculation/CalcStartConfig.ts b/app/src/types/calculation/CalcStartConfig.ts index 8f0171b76..526ae5b60 100644 --- a/app/src/types/calculation/CalcStartConfig.ts +++ b/app/src/types/calculation/CalcStartConfig.ts @@ -1,5 +1,5 @@ +import type { AppHouseholdInputEnvelope as Household } from '@/models/household/appTypes'; import type { Geography } from '@/types/ingredients/Geography'; -import type { Household } from '@/types/ingredients/Household'; import type { Simulation } from '@/types/ingredients/Simulation'; /** diff --git a/app/src/types/calculation/CalcStatus.ts b/app/src/types/calculation/CalcStatus.ts index c7165c375..1ad15c6dc 100644 --- a/app/src/types/calculation/CalcStatus.ts +++ b/app/src/types/calculation/CalcStatus.ts @@ -1,5 +1,5 @@ import { SocietyWideReportOutput } from '@/api/societyWideCalculation'; -import { HouseholdData } from '@/types/ingredients/Household'; +import type { AppHouseholdInputData as HouseholdData } from '@/models/household/appTypes'; import { CalcError } from './CalcError'; import { CalcMetadata } from './CalcMetadata'; diff --git a/app/src/types/calculation/household/HouseholdReportOutput.ts b/app/src/types/calculation/household/HouseholdReportOutput.ts index 810cc8ece..76c29d084 100644 --- a/app/src/types/calculation/household/HouseholdReportOutput.ts +++ b/app/src/types/calculation/household/HouseholdReportOutput.ts @@ -1,4 +1,4 @@ -import type { HouseholdData } from '@/types/ingredients/Household'; +import type { AppHouseholdInputData as HouseholdData } from '@/models/household/appTypes'; /** * Household report output structure diff --git a/app/src/types/ingredients/Household.ts b/app/src/types/ingredients/Household.ts index 050ffd969..62847d116 100644 --- a/app/src/types/ingredients/Household.ts +++ b/app/src/types/ingredients/Household.ts @@ -1,48 +1,53 @@ -import { countryIds } from '@/libs/countries'; +import type { CountryId } from '@/libs/countries'; +import type { + AppHouseholdInputEnvelope, + HouseholdScalar, + HouseholdYearValueMap, +} from '@/models/household/appTypes'; /** - * Household - The canonical household data structure for API communication - * This represents the actual household data structure used for simulations + * Legacy compatibility aliases. * - * Use this for: - * - API requests/responses - * - Simulation data - * - Data persistence - * - Normalized cache storage - * - * Key principles: - * - No ID until created via API - * - Country-agnostic structure - * - All group entities use same interface + * These names are compatibility aliases for pre-refactor app modules. They are + * not the canonical household type entry points. New household model work should + * import the explicit canonical/v1/v2 type families under `models/household`. */ -export interface Household { - id?: string; // Only present after API creation - countryId: (typeof countryIds)[number]; - householdData: HouseholdData; -} -/** - * The core household data structure matching API expectations - * All group entities (families, taxUnits, etc.) use the same interface - * The specific entities present depend on the country - */ -export interface HouseholdData { - people: Record; - [groupEntity: string]: Record | Record; -} +export type HouseholdValue = HouseholdScalar; +export type HouseholdYearMap = HouseholdYearValueMap; -/** - * Person entity - can contain any variables - */ +/** @deprecated Prefer `AppHouseholdInputPerson`. */ export interface HouseholdPerson { - [key: string]: any; + [key: string]: HouseholdYearMap | HouseholdValue; } -/** - * Group entity used for families, tax units, households, etc. - * Must have members array, can contain any other variables - */ +/** @deprecated Prefer `AppHouseholdInputGroup`. */ export interface HouseholdGroupEntity { members: string[]; - [key: string]: any; + [key: string]: HouseholdYearMap | HouseholdValue | string[]; +} + +export type HouseholdGroupMap = Record; + +/** @deprecated Prefer `AppHouseholdInputData`. */ +export interface HouseholdData { + people: Record; + households?: HouseholdGroupMap; + families?: HouseholdGroupMap; + taxUnits?: HouseholdGroupMap; + spmUnits?: HouseholdGroupMap; + maritalUnits?: HouseholdGroupMap; + benunits?: HouseholdGroupMap; + tax_units?: HouseholdGroupMap; + spm_units?: HouseholdGroupMap; + marital_units?: HouseholdGroupMap; +} + +/** @deprecated Prefer `AppHouseholdInputEnvelope`. */ +export interface Household extends AppHouseholdInputEnvelope { + id?: string; + countryId: CountryId; + householdData: HouseholdData; + label?: string | null; + year?: number | null; } diff --git a/app/src/types/ingredients/Population.ts b/app/src/types/ingredients/Population.ts index 90e80af5e..222c91882 100644 --- a/app/src/types/ingredients/Population.ts +++ b/app/src/types/ingredients/Population.ts @@ -1,5 +1,5 @@ +import type { AppHouseholdInputEnvelope } from '@/models/household/appTypes'; import { Geography } from './Geography'; -import { Household } from './Household'; /** * Population type for Redux state management @@ -8,6 +8,6 @@ import { Household } from './Household'; export interface Population { label?: string | null; isCreated?: boolean; - household?: Household | null; + household?: AppHouseholdInputEnvelope | null; geography?: Geography | null; } diff --git a/app/src/types/metadata/householdMetadata.ts b/app/src/types/metadata/householdMetadata.ts deleted file mode 100644 index bc32a8c23..000000000 --- a/app/src/types/metadata/householdMetadata.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Models the GET household api payload -export interface HouseholdMetadata { - id: string; - country_id: string; - label?: string | null; - api_version: string; - household_json: HouseholdData; - household_hash: string; -} - -// Models a household's json structure that can be reused in GET and POST api payloads -export interface HouseholdData { - people: Record; - families: Record; - tax_units: Record; - spm_units: Record; - households: Record; - marital_units: Record }>; - // UK-specific structure - benunits?: Record }>; -} - -export interface HouseholdPerson { - age: Record; - employment_income?: Record; - is_tax_unit_dependent?: Record; -} - -export interface MemberGroup { - members: string[]; -} - -// Extended household properties to support dynamic fields -export interface HouseholdProperties { - // US fields - state_name?: Record; - - // UK fields - brma?: Record; - region?: Record; - local_authority?: Record; - - // Allow for other dynamic fields based on basicInputs - [key: string]: Record | string[] | undefined; -} diff --git a/app/src/types/pathwayState/PopulationStateProps.ts b/app/src/types/pathwayState/PopulationStateProps.ts index 2f44596a6..bf78038e3 100644 --- a/app/src/types/pathwayState/PopulationStateProps.ts +++ b/app/src/types/pathwayState/PopulationStateProps.ts @@ -1,5 +1,5 @@ +import type { AppHouseholdInputEnvelope } from '@/models/household/appTypes'; import { Geography } from '@/types/ingredients/Geography'; -import { Household } from '@/types/ingredients/Household'; /** * PopulationStateProps - Local state interface for population within PathwayWrapper @@ -17,6 +17,6 @@ import { Household } from '@/types/ingredients/Household'; export interface PopulationStateProps { label: string | null; // Required field, can be null type: 'household' | 'geography' | null; // Tracks population type for easier management - household: Household | null; // Mutually exclusive with geography + household: AppHouseholdInputEnvelope | null; // Mutually exclusive with geography geography: Geography | null; // Mutually exclusive with household } diff --git a/app/src/types/payloads/HouseholdCreationPayload.ts b/app/src/types/payloads/HouseholdCreationPayload.ts index 72b53c337..b661fa591 100644 --- a/app/src/types/payloads/HouseholdCreationPayload.ts +++ b/app/src/types/payloads/HouseholdCreationPayload.ts @@ -1,10 +1,8 @@ -import { HouseholdData as APIHouseholdData } from '@/types/metadata/householdMetadata'; +import type { V1HouseholdCreateEnvelope } from '@/models/household/v1Types'; /** - * Payload format for creating a household via the API + * Legacy compatibility alias for the v1 household create payload. + * + * New household model work should prefer `V1HouseholdCreateEnvelope`. */ -export interface HouseholdCreationPayload { - country_id: string; - data: APIHouseholdData; - label?: string; -} +export type HouseholdCreationPayload = V1HouseholdCreateEnvelope; diff --git a/app/src/utils/HouseholdBuilder.ts b/app/src/utils/HouseholdBuilder.ts index f0e4ebeb4..cd6f9b1cb 100644 --- a/app/src/utils/HouseholdBuilder.ts +++ b/app/src/utils/HouseholdBuilder.ts @@ -1,10 +1,14 @@ import { countryIds } from '@/libs/countries'; +import type { + AppHouseholdInputEnvelope as Household, + AppHouseholdInputData as HouseholdData, + AppHouseholdInputGroup as HouseholdGroupEntity, + AppHouseholdInputPerson as HouseholdPerson, +} from '@/models/household/appTypes'; import { - Household, - HouseholdData, - HouseholdGroupEntity, - HouseholdPerson, -} from '@/types/ingredients/Household'; + ensureHouseholdGroupCollection, + getAllHouseholdGroupCollections, +} from './householdDataAccess'; // Country-specific default entities const COUNTRY_DEFAULT_ENTITIES = { @@ -229,7 +233,9 @@ export class HouseholdBuilder { firstSpmUnit.members.push(personKey); } - // Handle marital units - adults share one, children get their own + // Handle marital units for builder-created US households. + // Adults share one default marital unit. Children get their own + // marital units by default, matching the legacy builder semantics. if (!this.household.householdData.maritalUnits) { this.household.householdData.maritalUnits = {}; } @@ -247,15 +253,13 @@ export class HouseholdBuilder { maritalUnits['your marital unit'].members.push(personKey); } } else { - // Children get their own marital unit const childMaritalUnitKey = `${personKey}'s marital unit`; - const childCount = Object.keys(maritalUnits).filter((k) => - k.includes("'s marital unit") - ).length; - maritalUnits[childMaritalUnitKey] = { - members: [personKey], - marital_unit_id: { [this.currentYear]: childCount + 1 }, - }; + if (!maritalUnits[childMaritalUnitKey]) { + maritalUnits[childMaritalUnitKey] = { members: [] }; + } + if (!maritalUnits[childMaritalUnitKey].members.includes(personKey)) { + maritalUnits[childMaritalUnitKey].members.push(personKey); + } } // Ensure default household exists @@ -351,15 +355,7 @@ export class HouseholdBuilder { * Assign a person to a group entity */ assignToGroupEntity(personKey: string, entityName: string, groupKey: string): HouseholdBuilder { - // Ensure the entity type exists - if (!this.household.householdData[entityName]) { - this.household.householdData[entityName] = {}; - } - - const entities = this.household.householdData[entityName] as Record< - string, - HouseholdGroupEntity - >; + const entities = ensureHouseholdGroupCollection(this.household.householdData, entityName); // Create group if doesn't exist if (!entities[groupKey]) { @@ -405,10 +401,7 @@ export class HouseholdBuilder { variableName: string, value: any ): HouseholdBuilder { - const entities = this.household.householdData[entityName] as Record< - string, - HouseholdGroupEntity - >; + const entities = ensureHouseholdGroupCollection(this.household.householdData, entityName); if (!entities || !entities[groupKey]) { throw new Error(`Group ${groupKey} not found in ${entityName}`); } @@ -428,25 +421,11 @@ export class HouseholdBuilder { * Remove person from all group entities */ private removeFromAllGroups(personKey: string): void { - // Iterate through all properties of householdData - Object.keys(this.household.householdData).forEach((entityName) => { - // Skip 'people' as it's not a group entity - if (entityName === 'people') { - return; - } - - const entities = this.household.householdData[entityName] as Record< - string, - HouseholdGroupEntity - >; - - // Remove person from each group in this entity type - Object.values(entities).forEach((group) => { - if (group.members) { - const index = group.members.indexOf(personKey); - if (index > -1) { - group.members.splice(index, 1); - } + getAllHouseholdGroupCollections(this.household.householdData).forEach(({ groups }) => { + Object.values(groups).forEach((group) => { + const index = group.members.indexOf(personKey); + if (index > -1) { + group.members.splice(index, 1); } }); }); diff --git a/app/src/utils/HouseholdQueries.ts b/app/src/utils/HouseholdQueries.ts index 0c1768787..1d882b758 100644 --- a/app/src/utils/HouseholdQueries.ts +++ b/app/src/utils/HouseholdQueries.ts @@ -1,4 +1,9 @@ -import { Household, HouseholdGroupEntity, HouseholdPerson } from '@/types/ingredients/Household'; +import { Household as HouseholdModel } from '@/models/Household'; +import type { + AppHouseholdInputEnvelope as Household, + AppHouseholdInputPerson as HouseholdPerson, +} from '@/models/household/appTypes'; +import { getHouseholdGroupCollection, getHouseholdYearValue } from '@/utils/householdDataAccess'; /** * Extended person type with name (not ID - people don't have IDs) @@ -24,8 +29,8 @@ export function getAllPeople(household: Household): PersonWithName[] { export function getAdults(household: Household, year: string): PersonWithName[] { return Object.entries(household.householdData.people) .filter(([, person]) => { - const age = person.age?.[year]; - return age !== undefined && age >= 18; + const age = getHouseholdYearValue(person.age, year); + return typeof age === 'number' && age >= 18; }) .map(([name, person]) => ({ name, ...person })); } @@ -37,8 +42,8 @@ export function getAdults(household: Household, year: string): PersonWithName[] export function getChildren(household: Household, year: string): PersonWithName[] { return Object.entries(household.householdData.people) .filter(([, person]) => { - const age = person.age?.[year]; - return age !== undefined && age < 18; + const age = getHouseholdYearValue(person.age, year); + return typeof age === 'number' && age < 18; }) .map(([name, person]) => ({ name, ...person })); } @@ -76,9 +81,7 @@ export function getGroupVariable( variableName: string, year: string ): any { - const entities = household.householdData[entityName] as - | Record - | undefined; + const entities = getHouseholdGroupCollection(household.householdData, entityName); if (!entities) { return undefined; } @@ -89,16 +92,17 @@ export function getGroupVariable( } const variable = group[variableName]; - if (typeof variable === 'object' && variable !== null && year in variable) { - return variable[year]; - } - return undefined; + return getHouseholdYearValue(variable, year); } /** * Count total people in household */ export function getPersonCount(household: Household): number { + if (household instanceof HouseholdModel) { + return household.personCount; + } + return Object.keys(household.householdData.people).length; } @@ -120,7 +124,7 @@ export function getChildCount(household: Household, year: string): number { * Check if household has any people */ export function isEmpty(household: Household): boolean { - return Object.keys(household.householdData.people).length === 0; + return getPersonCount(household) === 0; } /** @@ -131,9 +135,7 @@ export function getGroupMembers( entityName: string, groupKey: string ): string[] { - const entities = household.householdData[entityName] as - | Record - | undefined; + const entities = getHouseholdGroupCollection(household.householdData, entityName); if (!entities) { return []; } @@ -149,9 +151,7 @@ export function getGroups( household: Household, entityName: string ): Array<{ key: string; members: string[] }> { - const entities = household.householdData[entityName] as - | Record - | undefined; + const entities = getHouseholdGroupCollection(household.householdData, entityName); if (!entities) { return []; } diff --git a/app/src/utils/HouseholdValidation.ts b/app/src/utils/HouseholdValidation.ts index 8d5ff8386..5ec244954 100644 --- a/app/src/utils/HouseholdValidation.ts +++ b/app/src/utils/HouseholdValidation.ts @@ -1,5 +1,9 @@ +import type { + AppHouseholdInputEnvelope as Household, + AppHouseholdInputGroup as HouseholdGroupEntity, +} from '@/models/household/appTypes'; import { RootState } from '@/store'; -import { Household, HouseholdGroupEntity } from '@/types/ingredients/Household'; +import { getHouseholdYearValue } from '@/utils/householdDataAccess'; import * as HouseholdQueries from './HouseholdQueries'; /** @@ -96,7 +100,7 @@ export const HouseholdValidation = { Object.entries(household.householdData.people).forEach(([personId, person]) => { // Only validate age if it's expected to exist (this would come from metadata) - if (!person.age || !(currentYear in person.age)) { + if (getHouseholdYearValue(person.age, currentYear) === undefined) { warnings.push({ code: 'MISSING_AGE', message: `Person ${personId} is missing age for year ${currentYear}`, diff --git a/app/src/utils/VariableResolver.ts b/app/src/utils/VariableResolver.ts index 753cfe535..4d41938f0 100644 --- a/app/src/utils/VariableResolver.ts +++ b/app/src/utils/VariableResolver.ts @@ -5,7 +5,15 @@ * getters/setters that access the correct location in household data. */ -import { Household } from '@/types/ingredients/Household'; +import type { AppHouseholdInputEnvelope } from '@/models/household/appTypes'; +import { + cloneHousehold, + ensureHouseholdGroupCollection, + ensureHouseholdGroupInstance, + getHouseholdGroupCollection, + getPreferredHouseholdGroupName, + isHouseholdYearMap, +} from './householdDataAccess'; export interface EntityInfo { entity: string; // e.g., "person", "tax_unit", "spm_unit" @@ -94,53 +102,55 @@ export function getVariableInfo(variableName: string, metadata: any): VariableIn } /** - * Get the group instance name for an entity type - * Maps entity plural to the default instance name used in household data - * - * NOTE: These names must match the conventions from policyengine-app (the legacy app). - * In particular, spm_units uses "your household" as the instance name (not "your spm unit"), - * which is the same key used by the households entity. This is intentional and matches - * the legacy app's defaultHouseholds.js structure. + * Get the entity data object from household based on plural name */ -export function getGroupName(entityPlural: string, _personName?: string): string { - const groupNameMap: Record = { - people: 'you', // Will be overridden by personName if provided - households: 'your household', - tax_units: 'your tax unit', - spm_units: 'your household', // Same as households - matches legacy policyengine-app - families: 'your family', - marital_units: 'your marital unit', - benunits: 'your benefit unit', - }; +function getEntityData( + household: AppHouseholdInputEnvelope, + entityPlural: string +): Record | null { + const householdData = household.householdData; - return groupNameMap[entityPlural] || entityPlural; + if (entityPlural === 'people') { + return householdData.people; + } + + return getHouseholdGroupCollection(householdData, entityPlural) ?? null; } -/** - * Get the entity data object from household based on plural name - */ -function getEntityData(household: Household, entityPlural: string): Record | null { +function ensureEntityData( + household: AppHouseholdInputEnvelope, + entityPlural: string +): Record | null { const householdData = household.householdData; - switch (entityPlural) { - case 'people': - return householdData.people; - case 'households': - return householdData.households; - case 'tax_units': - // Handle both snake_case and camelCase (HouseholdBuilder uses camelCase) - return householdData.tax_units || householdData.taxUnits; - case 'spm_units': - return householdData.spm_units || householdData.spmUnits; - case 'families': - return householdData.families; - case 'marital_units': - return householdData.marital_units || householdData.maritalUnits; - case 'benunits': - return householdData.benunits; - default: - return null; + if (entityPlural === 'people') { + return householdData.people; } + + return ensureHouseholdGroupCollection(householdData, entityPlural); +} + +function resolveEntityInstanceName( + household: AppHouseholdInputEnvelope, + entityInfo: EntityInfo, + entityName?: string +): string | null { + if (entityName) { + return entityName; + } + + if (entityInfo.isPerson) { + console.warn(`[VariableResolver] Person-level variable requires entityName`); + return null; + } + + const resolvedName = getPreferredHouseholdGroupName(household.householdData, entityInfo.plural); + if (!resolvedName) { + console.warn(`[VariableResolver] Entity ${entityInfo.plural} not found in household`); + return null; + } + + return resolvedName; } /** @@ -154,7 +164,7 @@ function getEntityData(household: Household, entityPlural: string): Record