policyengine-household-wizard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +116 -0
  3. package/dist/WizardReviewList-De9RTK_4.js +245 -0
  4. package/dist/WizardReviewList-tfP9LcqU.cjs +1 -0
  5. package/dist/index.cjs +1 -0
  6. package/dist/index.d.ts +3 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +35 -0
  9. package/dist/primitives/WizardNavigation.d.ts +19 -0
  10. package/dist/primitives/WizardNavigation.d.ts.map +1 -0
  11. package/dist/primitives/WizardOptionCard.d.ts +9 -0
  12. package/dist/primitives/WizardOptionCard.d.ts.map +1 -0
  13. package/dist/primitives/WizardProgress.d.ts +8 -0
  14. package/dist/primitives/WizardProgress.d.ts.map +1 -0
  15. package/dist/primitives/WizardReviewList.d.ts +14 -0
  16. package/dist/primitives/WizardReviewList.d.ts.map +1 -0
  17. package/dist/primitives/index.d.ts +11 -0
  18. package/dist/primitives/index.d.ts.map +1 -0
  19. package/dist/primitives/types.d.ts +30 -0
  20. package/dist/primitives/types.d.ts.map +1 -0
  21. package/dist/primitives/useWizardSteps.d.ts +3 -0
  22. package/dist/primitives/useWizardSteps.d.ts.map +1 -0
  23. package/dist/primitives.cjs +1 -0
  24. package/dist/primitives.d.ts +1 -0
  25. package/dist/primitives.js +8 -0
  26. package/dist/us-household/adapters/index.d.ts +2 -0
  27. package/dist/us-household/adapters/index.d.ts.map +1 -0
  28. package/dist/us-household/adapters/v1Payload.d.ts +68 -0
  29. package/dist/us-household/adapters/v1Payload.d.ts.map +1 -0
  30. package/dist/us-household/counties.d.ts +25 -0
  31. package/dist/us-household/counties.d.ts.map +1 -0
  32. package/dist/us-household/draft.d.ts +28 -0
  33. package/dist/us-household/draft.d.ts.map +1 -0
  34. package/dist/us-household/index.d.ts +9 -0
  35. package/dist/us-household/index.d.ts.map +1 -0
  36. package/dist/us-household/normalize.d.ts +11 -0
  37. package/dist/us-household/normalize.d.ts.map +1 -0
  38. package/dist/us-household/serialize.d.ts +4 -0
  39. package/dist/us-household/serialize.d.ts.map +1 -0
  40. package/dist/us-household/states.d.ts +15 -0
  41. package/dist/us-household/states.d.ts.map +1 -0
  42. package/dist/us-household/types.d.ts +80 -0
  43. package/dist/us-household/types.d.ts.map +1 -0
  44. package/dist/us-household/validate.d.ts +13 -0
  45. package/dist/us-household/validate.d.ts.map +1 -0
  46. package/dist/us-household-adapters.cjs +1 -0
  47. package/dist/us-household-adapters.d.ts +1 -0
  48. package/dist/us-household-adapters.js +92 -0
  49. package/dist/us-household.cjs +1 -0
  50. package/dist/us-household.d.ts +1 -0
  51. package/dist/us-household.js +556 -0
  52. package/package.json +76 -0
  53. package/src/index.ts +2 -0
  54. package/src/primitives/WizardNavigation.tsx +85 -0
  55. package/src/primitives/WizardOptionCard.tsx +55 -0
  56. package/src/primitives/WizardProgress.tsx +50 -0
  57. package/src/primitives/WizardReviewList.tsx +73 -0
  58. package/src/primitives/index.ts +15 -0
  59. package/src/primitives/types.ts +32 -0
  60. package/src/primitives/useWizardSteps.ts +150 -0
  61. package/src/styles.css +183 -0
  62. package/src/us-household/adapters/index.ts +15 -0
  63. package/src/us-household/adapters/v1Payload.ts +213 -0
  64. package/src/us-household/counties.ts +96 -0
  65. package/src/us-household/data/counties-by-state.json +12802 -0
  66. package/src/us-household/draft.ts +130 -0
  67. package/src/us-household/index.ts +59 -0
  68. package/src/us-household/normalize.ts +251 -0
  69. package/src/us-household/serialize.ts +153 -0
  70. package/src/us-household/states.ts +168 -0
  71. package/src/us-household/types.ts +82 -0
  72. package/src/us-household/validate.ts +129 -0
@@ -0,0 +1,168 @@
1
+ /**
2
+ * US states and territories supported by PolicyEngine US.
3
+ *
4
+ * Codes match the `state_code` enum in the PolicyEngine US country model so the
5
+ * draft's `state` field can be passed through to API payloads unchanged.
6
+ */
7
+ export interface USState {
8
+ code: string;
9
+ name: string;
10
+ }
11
+
12
+ export const US_STATES: ReadonlyArray<USState> = [
13
+ { code: 'AL', name: 'Alabama' },
14
+ { code: 'AK', name: 'Alaska' },
15
+ { code: 'AZ', name: 'Arizona' },
16
+ { code: 'AR', name: 'Arkansas' },
17
+ { code: 'CA', name: 'California' },
18
+ { code: 'CO', name: 'Colorado' },
19
+ { code: 'CT', name: 'Connecticut' },
20
+ { code: 'DE', name: 'Delaware' },
21
+ { code: 'DC', name: 'District of Columbia' },
22
+ { code: 'FL', name: 'Florida' },
23
+ { code: 'GA', name: 'Georgia' },
24
+ { code: 'HI', name: 'Hawaii' },
25
+ { code: 'ID', name: 'Idaho' },
26
+ { code: 'IL', name: 'Illinois' },
27
+ { code: 'IN', name: 'Indiana' },
28
+ { code: 'IA', name: 'Iowa' },
29
+ { code: 'KS', name: 'Kansas' },
30
+ { code: 'KY', name: 'Kentucky' },
31
+ { code: 'LA', name: 'Louisiana' },
32
+ { code: 'ME', name: 'Maine' },
33
+ { code: 'MD', name: 'Maryland' },
34
+ { code: 'MA', name: 'Massachusetts' },
35
+ { code: 'MI', name: 'Michigan' },
36
+ { code: 'MN', name: 'Minnesota' },
37
+ { code: 'MS', name: 'Mississippi' },
38
+ { code: 'MO', name: 'Missouri' },
39
+ { code: 'MT', name: 'Montana' },
40
+ { code: 'NE', name: 'Nebraska' },
41
+ { code: 'NV', name: 'Nevada' },
42
+ { code: 'NH', name: 'New Hampshire' },
43
+ { code: 'NJ', name: 'New Jersey' },
44
+ { code: 'NM', name: 'New Mexico' },
45
+ { code: 'NY', name: 'New York' },
46
+ { code: 'NC', name: 'North Carolina' },
47
+ { code: 'ND', name: 'North Dakota' },
48
+ { code: 'OH', name: 'Ohio' },
49
+ { code: 'OK', name: 'Oklahoma' },
50
+ { code: 'OR', name: 'Oregon' },
51
+ { code: 'PA', name: 'Pennsylvania' },
52
+ { code: 'RI', name: 'Rhode Island' },
53
+ { code: 'SC', name: 'South Carolina' },
54
+ { code: 'SD', name: 'South Dakota' },
55
+ { code: 'TN', name: 'Tennessee' },
56
+ { code: 'TX', name: 'Texas' },
57
+ { code: 'UT', name: 'Utah' },
58
+ { code: 'VT', name: 'Vermont' },
59
+ { code: 'VA', name: 'Virginia' },
60
+ { code: 'WA', name: 'Washington' },
61
+ { code: 'WV', name: 'West Virginia' },
62
+ { code: 'WI', name: 'Wisconsin' },
63
+ { code: 'WY', name: 'Wyoming' },
64
+ ];
65
+
66
+ const STATE_CODES = new Set(US_STATES.map((state) => state.code));
67
+ const STATE_NAME_BY_CODE: Record<string, string> = Object.fromEntries(
68
+ US_STATES.map((state) => [state.code, state.name]),
69
+ );
70
+
71
+ export function isUSStateCode(value: unknown): value is string {
72
+ return typeof value === 'string' && STATE_CODES.has(value);
73
+ }
74
+
75
+ export function getStateName(code: string | null | undefined): string | null {
76
+ if (!code) {
77
+ return null;
78
+ }
79
+ return STATE_NAME_BY_CODE[code] ?? null;
80
+ }
81
+
82
+ /**
83
+ * ZIP-prefix to state lookup. The first three digits of a ZIP code map to a
84
+ * state in nearly every case; this table covers the standard ranges and
85
+ * matches the data Coverage Compass already uses.
86
+ *
87
+ * Ranges that map to multiple states have been resolved to the dominant
88
+ * jurisdiction. Apps that need certainty for those edge cases should ask the
89
+ * user to confirm state directly.
90
+ */
91
+ const ZIP_PREFIX_RANGES: ReadonlyArray<readonly [number, number, string]> = [
92
+ [600, 999, 'MA'],
93
+ [1000, 2799, 'MA'],
94
+ [2800, 2999, 'RI'],
95
+ [3000, 3899, 'NH'],
96
+ [3900, 4999, 'ME'],
97
+ [5000, 5999, 'VT'],
98
+ [6000, 6999, 'CT'],
99
+ [7000, 8999, 'NJ'],
100
+ [9000, 9999, 'NY'],
101
+ [10000, 14999, 'NY'],
102
+ [15000, 19699, 'PA'],
103
+ [19700, 19999, 'DE'],
104
+ [20000, 20599, 'DC'],
105
+ [20600, 21999, 'MD'],
106
+ [22000, 24699, 'VA'],
107
+ [24700, 26999, 'WV'],
108
+ [27000, 28999, 'NC'],
109
+ [29000, 29999, 'SC'],
110
+ [30000, 31999, 'GA'],
111
+ [32000, 34999, 'FL'],
112
+ [35000, 36999, 'AL'],
113
+ [37000, 38599, 'TN'],
114
+ [38600, 39799, 'MS'],
115
+ [39800, 39999, 'GA'],
116
+ [40000, 42799, 'KY'],
117
+ [43000, 45999, 'OH'],
118
+ [46000, 47999, 'IN'],
119
+ [48000, 49999, 'MI'],
120
+ [50000, 52999, 'IA'],
121
+ [53000, 54999, 'WI'],
122
+ [55000, 56999, 'MN'],
123
+ [57000, 57999, 'SD'],
124
+ [58000, 58999, 'ND'],
125
+ [59000, 59999, 'MT'],
126
+ [60000, 62999, 'IL'],
127
+ [63000, 65999, 'MO'],
128
+ [66000, 67999, 'KS'],
129
+ [68000, 69999, 'NE'],
130
+ [70000, 71499, 'LA'],
131
+ [71600, 72999, 'AR'],
132
+ [73000, 74999, 'OK'],
133
+ [75000, 79999, 'TX'],
134
+ [80000, 81999, 'CO'],
135
+ [82000, 83199, 'WY'],
136
+ [83200, 83899, 'ID'],
137
+ [84000, 84999, 'UT'],
138
+ [85000, 86999, 'AZ'],
139
+ [87000, 88499, 'NM'],
140
+ [88900, 89999, 'NV'],
141
+ [90000, 96199, 'CA'],
142
+ [96700, 96899, 'HI'],
143
+ [97000, 97999, 'OR'],
144
+ [98000, 99499, 'WA'],
145
+ [99500, 99999, 'AK'],
146
+ ];
147
+
148
+ export function getStateFromZip(zip: string | null | undefined): string | null {
149
+ if (!zip) {
150
+ return null;
151
+ }
152
+ const digits = zip.replace(/\D/g, '');
153
+ if (digits.length < 3) {
154
+ return null;
155
+ }
156
+ const prefix = parseInt(digits.slice(0, Math.min(digits.length, 5)) || '0', 10);
157
+ if (Number.isNaN(prefix)) {
158
+ return null;
159
+ }
160
+
161
+ for (const [low, high, code] of ZIP_PREFIX_RANGES) {
162
+ if (prefix >= low && prefix <= high) {
163
+ return code;
164
+ }
165
+ }
166
+
167
+ return null;
168
+ }
@@ -0,0 +1,82 @@
1
+ export type USMaritalStatus = 'single' | 'married';
2
+
3
+ export type USPersonKind = 'adult' | 'dependent';
4
+
5
+ /**
6
+ * Person-level fields that the wizard surfaces uniformly across apps. Apps that
7
+ * do not collect a flag should leave it `undefined`; the adapters treat
8
+ * `undefined` differently from `false` (omitted vs. explicitly set).
9
+ */
10
+ export interface USPersonFlags {
11
+ isDisabled?: boolean;
12
+ isBlind?: boolean;
13
+ isFullTimeStudent?: boolean;
14
+ isPregnant?: boolean;
15
+ needsCare?: boolean;
16
+ }
17
+
18
+ export interface USPersonIncomes {
19
+ /** Wages and salaries; "employment_income" in PolicyEngine US. */
20
+ employmentIncome?: number;
21
+ selfEmploymentIncome?: number;
22
+ socialSecurityIncome?: number;
23
+ ssiAmount?: number;
24
+ ssdiAmount?: number;
25
+ pensionIncome?: number;
26
+ dividendIncome?: number;
27
+ taxableInterestIncome?: number;
28
+ rentalIncome?: number;
29
+ unemploymentCompensation?: number;
30
+ childSupportReceived?: number;
31
+ miscellaneousIncome?: number;
32
+ }
33
+
34
+ export interface USPersonDraft extends USPersonFlags, USPersonIncomes {
35
+ /** Stable identifier — used as the person key in the API payload. */
36
+ id: string;
37
+ kind: USPersonKind;
38
+ /** Age in whole years. `null` means the user has not entered a value yet. */
39
+ age: number | null;
40
+ /** Optional human-friendly label for review screens. */
41
+ label?: string;
42
+ /**
43
+ * App-controlled fields the wizard core does not understand. Adapters that
44
+ * own the app's payload can read this map; the shared US adapters ignore it.
45
+ */
46
+ extras?: Record<string, unknown>;
47
+ }
48
+
49
+ export interface USHouseholdDraft {
50
+ /** Two-letter state code (e.g. "CA"). `null` means the user has not picked. */
51
+ state: string | null;
52
+ /** PolicyEngine county enum code (e.g. "ALAMEDA_COUNTY_CA"). */
53
+ county: string | null;
54
+ /** Optional ZIP code captured at intake. Used to derive `state` when set. */
55
+ zip: string | null;
56
+ /**
57
+ * Marital status — explicitly NOT filing status. Apps that need a filing
58
+ * status must derive it (e.g. from presence of dependents).
59
+ */
60
+ maritalStatus: USMaritalStatus | null;
61
+ /**
62
+ * People in the household. Adults and dependents share the same shape; kind
63
+ * differentiates them. Order is significant for UI labels ("Adult 1", etc.).
64
+ */
65
+ people: USPersonDraft[];
66
+ /** Year the household is being modeled for. */
67
+ year: number;
68
+ /** App-controlled household-level fields ignored by the shared adapters. */
69
+ extras?: Record<string, unknown>;
70
+ }
71
+
72
+ export interface ValidationIssue {
73
+ /** Stable identifier for the rule (e.g. "state.required"). */
74
+ code: string;
75
+ /** Path into the draft (dotted), e.g. "state" or "people[1].age". */
76
+ path: string;
77
+ message: string;
78
+ }
79
+
80
+ export type ValidationResult =
81
+ | { ok: true; issues: never[] }
82
+ | { ok: false; issues: ValidationIssue[] };
@@ -0,0 +1,129 @@
1
+ import { isUSStateCode } from './states';
2
+ import { isCountyCode } from './counties';
3
+ import type { USHouseholdDraft, ValidationIssue, ValidationResult } from './types';
4
+
5
+ export interface ValidateOptions {
6
+ /**
7
+ * If true, require a county selection in addition to state. Apps where the
8
+ * county affects results (e.g. local taxes) can opt into this.
9
+ */
10
+ requireCounty?: boolean;
11
+ /** If true, require every person to have an age set. Defaults to true. */
12
+ requireAges?: boolean;
13
+ }
14
+
15
+ export function validate(
16
+ draft: USHouseholdDraft,
17
+ options: ValidateOptions = {},
18
+ ): ValidationResult {
19
+ const { requireCounty = false, requireAges = true } = options;
20
+ const issues: ValidationIssue[] = [];
21
+
22
+ if (!draft.state) {
23
+ issues.push({
24
+ code: 'state.required',
25
+ path: 'state',
26
+ message: 'State is required.',
27
+ });
28
+ } else if (!isUSStateCode(draft.state)) {
29
+ issues.push({
30
+ code: 'state.invalid',
31
+ path: 'state',
32
+ message: `Unknown state code "${draft.state}".`,
33
+ });
34
+ }
35
+
36
+ if (requireCounty && !draft.county) {
37
+ issues.push({
38
+ code: 'county.required',
39
+ path: 'county',
40
+ message: 'County is required.',
41
+ });
42
+ } else if (draft.county && !isCountyCode(draft.county)) {
43
+ issues.push({
44
+ code: 'county.invalid',
45
+ path: 'county',
46
+ message: `Unknown county code "${draft.county}".`,
47
+ });
48
+ }
49
+
50
+ if (!draft.maritalStatus) {
51
+ issues.push({
52
+ code: 'maritalStatus.required',
53
+ path: 'maritalStatus',
54
+ message: 'Marital status is required.',
55
+ });
56
+ }
57
+
58
+ const adults = draft.people.filter((person) => person.kind === 'adult');
59
+ if (adults.length === 0) {
60
+ issues.push({
61
+ code: 'people.adults.required',
62
+ path: 'people',
63
+ message: 'At least one adult is required.',
64
+ });
65
+ }
66
+
67
+ if (draft.maritalStatus === 'married' && adults.length < 2) {
68
+ issues.push({
69
+ code: 'people.adults.marriedRequiresTwo',
70
+ path: 'people',
71
+ message: 'Married households need two adults.',
72
+ });
73
+ }
74
+
75
+ draft.people.forEach((person, index) => {
76
+ if (requireAges && (person.age === null || person.age === undefined)) {
77
+ issues.push({
78
+ code: 'person.age.required',
79
+ path: `people[${index}].age`,
80
+ message: `Age is required for ${person.kind === 'adult' ? `adult ${index + 1}` : `dependent ${index + 1}`}.`,
81
+ });
82
+ }
83
+
84
+ if (person.age !== null && person.age !== undefined) {
85
+ if (!Number.isFinite(person.age) || person.age < 0 || person.age > 120) {
86
+ issues.push({
87
+ code: 'person.age.outOfRange',
88
+ path: `people[${index}].age`,
89
+ message: 'Age must be between 0 and 120.',
90
+ });
91
+ }
92
+ if (person.kind === 'dependent' && person.age >= 24) {
93
+ // PolicyEngine's tax dependent definition tops out around 24; flag
94
+ // older dependents so the app can ask the user to confirm. We only
95
+ // warn, not fail; some state benefit rules accept older dependents.
96
+ // This issue is reported with a distinct code so apps can downgrade.
97
+ issues.push({
98
+ code: 'person.age.dependentTooOld',
99
+ path: `people[${index}].age`,
100
+ message: `Dependent ${index + 1} is older than 23; confirm they qualify.`,
101
+ });
102
+ }
103
+ if (person.kind === 'adult' && person.age < 14) {
104
+ issues.push({
105
+ code: 'person.age.adultTooYoung',
106
+ path: `people[${index}].age`,
107
+ message: `Adult ${index + 1} is younger than 14.`,
108
+ });
109
+ }
110
+ }
111
+ });
112
+
113
+ if (!Number.isFinite(draft.year) || draft.year < 1900 || draft.year > 2200) {
114
+ issues.push({
115
+ code: 'year.invalid',
116
+ path: 'year',
117
+ message: `Year ${draft.year} is out of range.`,
118
+ });
119
+ }
120
+
121
+ if (issues.length === 0) {
122
+ return { ok: true, issues: [] as never[] };
123
+ }
124
+ return { ok: false, issues };
125
+ }
126
+
127
+ export function isComplete(draft: USHouseholdDraft, options: ValidateOptions = {}): boolean {
128
+ return validate(draft, options).ok;
129
+ }