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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 162 additions & 13 deletions app/src/models/Household.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ const BUILDER_PRIMARY_PERSON_NAME = 'you';
const BUILDER_DEFAULT_PARTNER_NAME = 'your partner';
const BUILDER_DEPENDENT_ORDINALS = ['first', 'second', 'third', 'fourth', 'fifth'];

/**
* policyengine-uk person input naming the benefit unit's claimant and partner. Without it the
* model infers the couple from ages, so an adult dependant can be read as the partner.
*/
export const UK_CLAIMANT_OR_PARTNER_VARIABLE = 'is_claimant_or_partner';

/** The unit whose members the builder's partner and child controls manage, by country. */
const BUILDER_FAMILY_GROUPS: Partial<Record<CountryId, HouseholdGroupAppKey>> = {
uk: 'benunits',
us: 'taxUnits',
};

export interface PersonWithName extends AppHouseholdInputPerson {
name: string;
}
Expand Down Expand Up @@ -147,7 +159,8 @@ function ensureGroupCollection(
return householdData[entityName] as Record<string, AppHouseholdInputGroup>;
}

function addPersonToFirstGroup(
/** Adds the person to the group holding "you" (the builder's own unit), else the first group. */
function addPersonToBuilderGroup(
groupMap: Record<string, AppHouseholdInputGroup>,
defaultGroupName: string,
personKey: string
Expand All @@ -156,9 +169,11 @@ function addPersonToFirstGroup(
groupMap[defaultGroupName] = { members: [] };
}

const firstGroup = Object.values(groupMap)[0];
if (!firstGroup.members.includes(personKey)) {
firstGroup.members.push(personKey);
const groups = Object.values(groupMap);
const builderGroup =
groups.find((group) => group.members.includes(BUILDER_PRIMARY_PERSON_NAME)) ?? groups[0];
if (!builderGroup.members.includes(personKey)) {
builderGroup.members.push(personKey);
}
}

Expand Down Expand Up @@ -351,6 +366,34 @@ function getBuilderDependentName(index: number): string {
return `your ${BUILDER_DEPENDENT_ORDINALS[index] || `${index + 1}th`} dependent`;
}

/** The index getBuilderDependentName gave this name, or -1 for any other name. */
function getBuilderDependentIndex(personKey: string): number {
const ordinal = /^your (\S+) dependent$/.exec(personKey)?.[1];
if (!ordinal) {
return -1;
}

const namedIndex = BUILDER_DEPENDENT_ORDINALS.indexOf(ordinal);
if (namedIndex !== -1) {
return namedIndex;
}

const numbered = /^(\d+)th$/.exec(ordinal)?.[1];
return numbered ? Number(numbered) - 1 : -1;
}

/** Everyone who shares a group with the person, the person included. */
function getGroupMatesOf(
groups: AppHouseholdInputGroupMap | undefined,
personKey: string
): Set<string> {
return new Set(
Object.values(groups ?? {})
.filter((group) => group.members.includes(personKey))
.flatMap((group) => group.members)
);
}

function getUniqueBuilderPersonName(existingKeys: Set<string>, preferredName: string): string {
if (!existingKeys.has(preferredName)) {
return preferredName;
Expand Down Expand Up @@ -392,7 +435,7 @@ function applyDefaultHouseholdGroups(
householdData: AppHouseholdInputData,
personKey: string
): void {
addPersonToFirstGroup(
addPersonToBuilderGroup(
ensureGroupCollection(householdData, 'households'),
'your household',
personKey
Expand All @@ -404,13 +447,17 @@ function applyUSDefaultGroups(
personKey: string,
personType: 'adult' | 'child'
): void {
addPersonToFirstGroup(
addPersonToBuilderGroup(
ensureGroupCollection(householdData, 'taxUnits'),
'your tax unit',
personKey
);
addPersonToFirstGroup(ensureGroupCollection(householdData, 'families'), 'your family', personKey);
addPersonToFirstGroup(
addPersonToBuilderGroup(
ensureGroupCollection(householdData, 'families'),
'your family',
personKey
);
addPersonToBuilderGroup(
ensureGroupCollection(householdData, 'spmUnits'),
'your household',
personKey
Expand All @@ -425,7 +472,7 @@ function applyUSDefaultGroups(
}

function applyUKDefaultGroups(householdData: AppHouseholdInputData, personKey: string): void {
addPersonToFirstGroup(
addPersonToBuilderGroup(
ensureGroupCollection(householdData, 'benunits'),
'your benefit unit',
personKey
Expand Down Expand Up @@ -884,8 +931,25 @@ export class Household extends BaseModel<HouseholdModelData> {
return BUILDER_DEFAULT_PARTNER_NAME;
}

const adults = this.getAdults(year).map((person) => person.name);
return adults.find((personKey) => personKey !== resolvedPrimaryPersonKey) ?? null;
// Only an explicit partner counts. Age never makes one: a dependant aged 25 stays a dependant.
// A claimant-or-partner flag names a partner only when the primary person is flagged as a
// claimant too, and only inside their own family unit: a flagged member of another unit is
// that unit's own claimant.
const familyMembers = this.getBuilderFamilyMembers(resolvedPrimaryPersonKey);
const isFlaggedClaimantOrPartner = (personKey: string) =>
this.getPersonVariableAtYear(personKey, UK_CLAIMANT_OR_PARTNER_VARIABLE, year) === true;
if (!familyMembers || !isFlaggedClaimantOrPartner(resolvedPrimaryPersonKey)) {
return null;
}

return (
this.getSortedPersonNames().find(
(personKey) =>
personKey !== resolvedPrimaryPersonKey &&
familyMembers.has(personKey) &&
isFlaggedClaimantOrPartner(personKey)
) ?? null
);
}

getBuilderChildKeys(
Expand All @@ -896,20 +960,40 @@ export class Household extends BaseModel<HouseholdModelData> {
const resolvedPrimaryPersonKey = primaryPersonKey ?? this.getBuilderPrimaryPersonKey(year);
const resolvedPartnerKey =
partnerKey ?? this.getBuilderPartnerKey(year, resolvedPrimaryPersonKey);
const familyMembers = resolvedPrimaryPersonKey
? this.getBuilderFamilyMembers(resolvedPrimaryPersonKey)
: null;

return Household.sortPersonNames(
Object.entries(this.appInputData.people)
.filter(([personKey, person]) => {
if (personKey === resolvedPrimaryPersonKey || personKey === resolvedPartnerKey) {
return false;
}
if (familyMembers && !familyMembers.has(personKey)) {
return false;
}

return isManagedBuilderChild(person, year);
})
.map(([personKey]) => personKey)
);
}

/**
* Members of the primary person's own family unit (UK benefit unit, US tax unit). The builder's
* partner and child controls manage only these; members of another unit belong to that unit's
* own claimant. Null when the country has no such unit or the primary person is in none, and
* then the whole household counts.
*/
private getBuilderFamilyMembers(primaryPersonKey: string): Set<string> | null {
const groupKey = BUILDER_FAMILY_GROUPS[this.countryId];
const members = groupKey
? getGroupMatesOf(getGroupCollectionFromData(this.appInputData, groupKey), primaryPersonKey)
: new Set<string>();
return members.size > 0 ? members : null;
}

deriveBuilderComposition(year: string): HouseholdBuilderComposition {
const people = this.getSortedPersonNames();
const primaryPersonKey = this.getBuilderPrimaryPersonKey(year);
Expand All @@ -926,6 +1010,55 @@ export class Household extends BaseModel<HouseholdModelData> {
};
}

/**
* Benefit-unit roles of a UK builder household: "you" and the explicit partner are the
* claimant and partner, and every other member (a dependant of any age) is neither.
*
* Returns null unless the household has the builder's structure: a person named "you" and one
* benefit unit holding everyone. Roles are all or nothing: policyengine-core gives anyone
* without an input the variable's default (false), not its formula, so roles for one unit
* would make another unit's claimant a non-claimant. Without roles, policyengine-uk infers
* them as before.
*/
getBuilderClaimantRoles(year: string): Record<string, boolean> | null {
const benefitUnits = Object.values(this.appInputData.benunits ?? {});
const hasBuilderStructure =
this.countryId === 'uk' &&
BUILDER_PRIMARY_PERSON_NAME in this.appInputData.people &&
benefitUnits.length === 1 &&
this.personNames.every((personKey) => benefitUnits[0].members.includes(personKey));
if (!hasBuilderStructure) {
return null;
}

const { primaryPersonKey, partnerKey } = this.deriveBuilderComposition(year);
return Object.fromEntries(
this.personNames.map((personKey) => [
personKey,
personKey === primaryPersonKey || personKey === partnerKey,
])
);
}

/** Records getBuilderClaimantRoles for the year, keeping any value a person already has. */
withBuilderClaimantRoles(year: string): Household {
const roles = this.getBuilderClaimantRoles(year);
if (!roles) {
return this;
}

const nextData = cloneAppHouseholdInputData(this.appInputData);
const normalizedYear = normalizeYear(year);
for (const [personKey, isClaimantOrPartner] of Object.entries(roles)) {
const person = nextData.people[personKey];
if (getYearValue(person[UK_CLAIMANT_OR_PARTNER_VARIABLE], normalizedYear) === undefined) {
setYearValue(person, UK_CLAIMANT_OR_PARTNER_VARIABLE, normalizedYear, isClaimantOrPartner);
}
}

return this.withHouseholdData(nextData);
}

getHeadPersonName(year: string | null | undefined): string | null {
const people = this.appInputData.people;
const orderedPeople = this.getSortedPersonNames();
Expand Down Expand Up @@ -1175,9 +1308,25 @@ export class Household extends BaseModel<HouseholdModelData> {
const parentIds = [composition.primaryPersonKey, composition.partnerKey].filter(
Boolean
) as string[];
// Number new children after every dependant in the family unit, adult dependants included,
// and after the highest dependant ordinal anyone uses, so names never collide or repeat.
const familyMembers = composition.primaryPersonKey
? this.getBuilderFamilyMembers(composition.primaryPersonKey)
: null;
const dependantCount = composition.people.filter(
(personKey) =>
!parentIds.includes(personKey) && (!familyMembers || familyMembers.has(personKey))
).length;
const firstNewIndex = Math.max(
dependantCount,
...composition.people.map((personKey) => getBuilderDependentIndex(personKey) + 1)
);

for (let index = composition.childKeys.length; index < newCount; index += 1) {
const childKey = getUniqueBuilderPersonName(existingKeys, getBuilderDependentName(index));
for (let offset = 0; offset < newCount - composition.childKeys.length; offset += 1) {
const childKey = getUniqueBuilderPersonName(
existingKeys,
getBuilderDependentName(firstNewIndex + offset)
);
existingKeys.add(childKey);
nextHousehold = nextHousehold.addChild(childKey, BUILDER_DEFAULT_CHILD_AGE, parentIds, {
employment_income: BUILDER_DEFAULT_EMPLOYMENT_INCOME,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export function useModifyReportSubmission({
onSuccess,
}: UseModifyReportSubmissionArgs): UseModifyReportSubmissionReturn {
const currentLawId = useSelector((state: RootState) => state.metadata.currentLawId);
const metadata = useSelector((state: RootState) => state.metadata);
const manager = useCalcOrchestratorManager();
const updateReportAssociation = useUpdateReportAssociation();
const queryClient = useQueryClient();
Expand All @@ -68,6 +69,7 @@ export function useModifyReportSubmission({
countryId,
currentLawId,
reportYear: reportState.year,
metadata,
});

const reportPayload = ReportAdapter.toCreationPayload({
Expand All @@ -78,7 +80,7 @@ export function useModifyReportSubmission({
} as Report);

return { simulationIds, simulations, simulationStates, reportPayload };
}, [reportState, countryId, currentLawId]);
}, [reportState, countryId, currentLawId, metadata]);

/**
* Shared logic: start calculation after a report is created.
Expand Down
3 changes: 3 additions & 0 deletions app/src/pages/reportBuilder/hooks/useReportSubmission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export function useReportSubmission({
onSuccess,
}: UseReportSubmissionArgs): UseReportSubmissionReturn {
const currentLawId = useSelector((state: RootState) => state.metadata.currentLawId);
const metadata = useSelector((state: RootState) => state.metadata);
const [submissionError, setSubmissionError] = useState<Error | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const { createReport } = useCreateReport(reportState.label || undefined);
Expand All @@ -75,6 +76,7 @@ export function useReportSubmission({
countryId,
currentLawId,
reportYear: reportState.year,
metadata,
});

journeyProfiler?.markEnd?.('report-submit-simulations', 'api-call');
Expand Down Expand Up @@ -138,6 +140,7 @@ export function useReportSubmission({
reportState,
countryId,
currentLawId,
metadata,
createReport,
onSuccess,
]);
Expand Down
15 changes: 11 additions & 4 deletions app/src/pages/reportBuilder/modals/HouseholdCreationModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Household as HouseholdModel } from '@/models/Household';
import { EditableLabel } from '@/pages/reportBuilder/components/EditableLabel';
import { RootState } from '@/store';
import { PopulationStateProps } from '@/types/pathwayState';
import { withSupportedBuilderClaimantRoles } from '@/utils/builderClaimantRoles';
import { HouseholdValidation } from '@/utils/HouseholdValidation';
import { getModelMetadataError } from '@/utils/spmSelection';
import { BROWSE_MODAL_CONFIG, FONT_SIZES, INGREDIENT_COLORS } from '../constants';
Expand Down Expand Up @@ -224,10 +225,15 @@ export function HouseholdCreationModal({

setSaveError(null);
try {
const payload = householdToSave.toV1CreationPayload();
const householdWithRoles = withSupportedBuilderClaimantRoles(
householdToSave,
metadata,
reportYear
);
const payload = householdWithRoles.toV1CreationPayload();
const result = await createHouseholdWithLabel(payload, householdToSave.label ?? undefined);
const householdId = String(result.result.household_id);
const savedHousehold = householdToSave
const savedHousehold = householdWithRoles
.withId(householdId)
.withLabel(householdToSave.label ?? null);
persistCreatedHousehold(savedHousehold);
Expand Down Expand Up @@ -281,11 +287,12 @@ export function HouseholdCreationModal({
return;
}

const householdWithRoles = withSupportedBuilderClaimantRoles(household, metadata, reportYear);
let updatedAssociation = await updateHouseholdAssociation.mutateAsync({
userHouseholdId: association.id,
updates: {},
association,
nextHousehold: household,
nextHousehold: householdWithRoles,
});

const desiredLabel = household.label ?? undefined;
Expand All @@ -296,7 +303,7 @@ export function HouseholdCreationModal({
});
}

const savedHousehold = household
const savedHousehold = householdWithRoles
.withId(updatedAssociation.householdId)
.withLabel(updatedAssociation.label ?? desiredLabel ?? null);

Expand Down
Loading
Loading