hccinfhir 0.2.2__py3-none-any.whl → 0.2.4__py3-none-any.whl

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.
@@ -1,46 +1,124 @@
1
- from typing import List, Optional, Dict, Any, Tuple
1
+ """
2
+ X12 834 Benefit Enrollment Parser for California DHCS Medi-Cal
3
+
4
+ Extracts enrollment and demographic data from 834 transactions with focus on:
5
+ - Risk adjustment fields (dual eligibility, OREC/CREC, SNP, LTI)
6
+ - CA DHCS FAME-specific fields
7
+ - HCP (Health Care Plan) coverage history
8
+ """
9
+
10
+ from typing import List, Optional, Dict, Tuple
2
11
  from pydantic import BaseModel
3
12
  from datetime import datetime, date
4
- from hccinfhir.datamodels import Demographics, EnrollmentData
13
+ from hccinfhir.datamodels import Demographics, EnrollmentData, HCPCoveragePeriod
5
14
  from hccinfhir.constants import (
6
15
  VALID_DUAL_CODES,
7
16
  FULL_BENEFIT_DUAL_CODES,
8
17
  PARTIAL_BENEFIT_DUAL_CODES,
9
- VALID_OREC_VALUES,
10
18
  VALID_CREC_VALUES,
11
19
  X12_SEX_CODE_MAPPING,
12
20
  NON_DUAL_CODE,
13
21
  map_medicare_status_to_dual_code,
14
22
  map_aid_code_to_dual_status,
15
23
  )
16
-
17
- TRANSACTION_TYPES = {
18
- "005010X220A1": "834", # Benefit Enrollment and Maintenance
24
+ from hccinfhir.utils import load_race_ethnicity
25
+
26
+ # Load race/ethnicity mapping at module level
27
+ _RACE_ETHNICITY_MAPPING: Optional[Dict[str, str]] = None
28
+
29
+
30
+ def _get_race_ethnicity_mapping() -> Dict[str, str]:
31
+ """Lazy load race/ethnicity mapping"""
32
+ global _RACE_ETHNICITY_MAPPING
33
+ if _RACE_ETHNICITY_MAPPING is None:
34
+ try:
35
+ _RACE_ETHNICITY_MAPPING = load_race_ethnicity()
36
+ except (FileNotFoundError, RuntimeError):
37
+ _RACE_ETHNICITY_MAPPING = {}
38
+ return _RACE_ETHNICITY_MAPPING
39
+
40
+ # Constants
41
+ TRANSACTION_TYPES = {"005010X220A1": "834"}
42
+
43
+ LANGUAGE_CODES = {
44
+ 'SPA': 'Spanish', 'ENG': 'English', 'CHI': 'Chinese', 'VIE': 'Vietnamese',
45
+ 'KOR': 'Korean', 'TAG': 'Tagalog', 'ARM': 'Armenian', 'FAR': 'Farsi',
46
+ 'ARA': 'Arabic', 'RUS': 'Russian', 'JPN': 'Japanese', 'HIN': 'Hindi',
47
+ 'CAM': 'Cambodian', 'HMO': 'Hmong', 'LAO': 'Lao', 'THA': 'Thai',
19
48
  }
20
49
 
50
+ MEDICARE_KEYWORDS = {'MEDICARE', 'MA', 'PART A', 'PART B', 'PART C', 'PART D', 'MEDICARE ADVANTAGE', 'MA-PD'}
51
+ MEDICAID_KEYWORDS = {'MEDICAID', 'MEDI-CAL', 'MEDI CAL', 'MEDIC-AID', 'LTC'}
52
+ SNP_KEYWORDS = {'SNP', 'SPECIAL NEEDS', 'D-SNP', 'DSNP', 'DUAL ELIGIBLE SNP'}
53
+ LTI_KEYWORDS = {'LTC', 'LONG TERM CARE', 'LONG-TERM CARE', 'NURSING HOME', 'SKILLED NURSING', 'SNF', 'INSTITUTIONALIZED'}
54
+
55
+
56
+ class HCPContext(BaseModel):
57
+ """Single HD loop (HCP coverage period)"""
58
+ start_date: Optional[str] = None
59
+ end_date: Optional[str] = None
60
+ hcp_code: Optional[str] = None
61
+ hcp_status: Optional[str] = None
62
+ aid_codes: Optional[str] = None
63
+
64
+
21
65
  class MemberContext(BaseModel):
22
66
  """Tracks member-level data across segments within 834 transaction"""
23
67
  # Identifiers
24
68
  member_id: Optional[str] = None
25
- mbi: Optional[str] = None # Medicare Beneficiary Identifier
69
+ mbi: Optional[str] = None
26
70
  medicaid_id: Optional[str] = None
71
+ hic: Optional[str] = None
72
+
73
+ # Name
74
+ first_name: Optional[str] = None
75
+ last_name: Optional[str] = None
76
+ middle_name: Optional[str] = None
27
77
 
28
78
  # Demographics
29
79
  dob: Optional[str] = None
30
80
  sex: Optional[str] = None
81
+ race: Optional[str] = None
82
+ language: Optional[str] = None
83
+ death_date: Optional[str] = None
84
+
85
+ # Address
86
+ address_1: Optional[str] = None
87
+ address_2: Optional[str] = None
88
+ city: Optional[str] = None
89
+ state: Optional[str] = None
90
+ zip: Optional[str] = None
91
+ phone: Optional[str] = None
31
92
 
32
93
  # Coverage Status
33
- maintenance_type: Optional[str] = None # 001=Change, 021=Add, 024=Cancel, 025=Reinstate
94
+ maintenance_type: Optional[str] = None
95
+ maintenance_reason_code: Optional[str] = None
96
+ benefit_status_code: Optional[str] = None
34
97
  coverage_start_date: Optional[str] = None
35
98
  coverage_end_date: Optional[str] = None
36
99
 
37
100
  # Medicare/Medicaid Status
38
101
  has_medicare: bool = False
39
102
  has_medicaid: bool = False
40
- medicare_status_code: Optional[str] = None # QMB, SLMB, QI, etc.
103
+ medicare_status_code: Optional[str] = None
41
104
  medi_cal_aid_code: Optional[str] = None
42
105
  dual_elgbl_cd: Optional[str] = None
43
106
 
107
+ # CA DHCS / FAME Specific
108
+ fame_county_id: Optional[str] = None
109
+ case_number: Optional[str] = None
110
+ fame_card_issue_date: Optional[str] = None
111
+ fame_redetermination_date: Optional[str] = None
112
+ fame_death_date: Optional[str] = None
113
+ primary_aid_code: Optional[str] = None
114
+ carrier_code: Optional[str] = None
115
+ fed_contract_number: Optional[str] = None
116
+ client_reporting_cat: Optional[str] = None
117
+ res_addr_flag: Optional[str] = None
118
+ reas_add_ind: Optional[str] = None
119
+ res_zip_deliv_code: Optional[str] = None
120
+ cin_check_digit: Optional[str] = None
121
+
44
122
  # Risk Adjustment Fields
45
123
  orec: Optional[str] = None
46
124
  crec: Optional[str] = None
@@ -48,93 +126,75 @@ class MemberContext(BaseModel):
48
126
  low_income: bool = False
49
127
  lti: bool = False
50
128
 
51
- # Helper methods for EnrollmentData - added as standalone functions
52
- def enrollment_to_demographics(enrollment: EnrollmentData) -> Demographics:
53
- """Convert EnrollmentData to Demographics model for risk calculation"""
54
- return Demographics(
55
- age=enrollment.age or 0,
56
- sex=enrollment.sex or 'M',
57
- dual_elgbl_cd=enrollment.dual_elgbl_cd,
58
- orec=enrollment.orec or '',
59
- crec=enrollment.crec or '',
60
- new_enrollee=enrollment.new_enrollee,
61
- snp=enrollment.snp,
62
- low_income=enrollment.low_income,
63
- lti=enrollment.lti
64
- )
129
+ # HCP Info
130
+ hcp_code: Optional[str] = None
131
+ hcp_status: Optional[str] = None
132
+ amount_qualifier: Optional[str] = None
133
+ amount: Optional[float] = None
65
134
 
66
- def is_losing_medicaid(enrollment: EnrollmentData, within_days: int = 90) -> bool:
67
- """Check if member will lose Medicaid within specified days
135
+ # HCP History
136
+ hcp_history: List[HCPContext] = []
137
+ current_hcp: Optional[HCPContext] = None
68
138
 
69
- Args:
70
- enrollment: EnrollmentData object
71
- within_days: Number of days to look ahead (default 90)
72
139
 
73
- Returns:
74
- True if Medicaid coverage ends within specified days
75
- """
76
- if not enrollment.coverage_end_date or not enrollment.has_medicaid:
77
- return False
78
-
79
- try:
80
- end_date = datetime.strptime(enrollment.coverage_end_date, "%Y-%m-%d").date()
81
- today = date.today()
82
- days_until_end = (end_date - today).days
83
-
84
- return 0 <= days_until_end <= within_days
85
- except (ValueError, AttributeError):
86
- return False
87
-
88
- def is_medicaid_terminated(enrollment: EnrollmentData) -> bool:
89
- """Check if Medicaid coverage is being terminated (maintenance type 024)"""
90
- return enrollment.maintenance_type == '024'
91
-
92
- def medicaid_status_summary(enrollment: EnrollmentData) -> Dict[str, Any]:
93
- """Get summary of Medicaid coverage status for monitoring
140
+ # ============================================================================
141
+ # Utility Functions
142
+ # ============================================================================
94
143
 
95
- Args:
96
- enrollment: EnrollmentData object
144
+ def get_segment_value(segment: List[str], index: int, default: Optional[str] = None) -> Optional[str]:
145
+ """Safely get value from segment at given index"""
146
+ if len(segment) > index and segment[index]:
147
+ return segment[index]
148
+ return default
97
149
 
98
- Returns:
99
- Dictionary with Medicaid status, dual eligibility, and loss indicators
100
- """
101
- return {
102
- 'member_id': enrollment.member_id,
103
- 'has_medicaid': enrollment.has_medicaid,
104
- 'has_medicare': enrollment.has_medicare,
105
- 'dual_status': enrollment.dual_elgbl_cd,
106
- 'is_full_benefit_dual': enrollment.is_full_benefit_dual,
107
- 'is_partial_benefit_dual': enrollment.is_partial_benefit_dual,
108
- 'coverage_end_date': enrollment.coverage_end_date,
109
- 'is_termination': is_medicaid_terminated(enrollment),
110
- 'losing_medicaid_30d': is_losing_medicaid(enrollment, 30),
111
- 'losing_medicaid_60d': is_losing_medicaid(enrollment, 60),
112
- 'losing_medicaid_90d': is_losing_medicaid(enrollment, 90)
113
- }
114
150
 
115
151
  def parse_date(date_str: str) -> Optional[str]:
116
- """Convert 8-digit date string to ISO format YYYY-MM-DD"""
117
- if not isinstance(date_str, str) or len(date_str) != 8:
152
+ """Convert 8-digit date string (YYYYMMDD) to ISO format (YYYY-MM-DD)"""
153
+ if not date_str or len(date_str) < 8:
118
154
  return None
119
155
  try:
120
156
  year, month, day = int(date_str[:4]), int(date_str[4:6]), int(date_str[6:8])
121
- if not (1900 <= year <= 2100 and 1 <= month <= 12 and 1 <= day <= 31):
122
- return None
123
- return f"{year:04d}-{month:02d}-{day:02d}"
157
+ if 1900 <= year <= 2100 and 1 <= month <= 12 and 1 <= day <= 31:
158
+ return f"{year:04d}-{month:02d}-{day:02d}"
159
+ except (ValueError, IndexError):
160
+ pass
161
+ return None
162
+
163
+
164
+ def parse_yymmdd(date_str: str) -> Optional[str]:
165
+ """Convert 6-digit date (YYMMDD) to ISO format"""
166
+ if not date_str or len(date_str) < 6:
167
+ return None
168
+ try:
169
+ yy = int(date_str[:2])
170
+ year = 2000 + yy if yy < 50 else 1900 + yy
171
+ return f"{year}-{date_str[2:4]}-{date_str[4:6]}"
124
172
  except (ValueError, IndexError):
125
173
  return None
126
174
 
175
+
176
+ def strip_leading_zeros(value: str) -> str:
177
+ """Strip leading zeros but keep at least one character"""
178
+ return value.lstrip('0') or value
179
+
180
+
181
+ def get_composite_part(value: str, index: int) -> Optional[str]:
182
+ """Get part from semicolon-delimited composite value (0-indexed)"""
183
+ if not value:
184
+ return None
185
+ parts = value.split(';')
186
+ if len(parts) > index and parts[index]:
187
+ return parts[index]
188
+ return None
189
+
190
+
127
191
  def calculate_age(dob: str, reference_date: Optional[str] = None) -> Optional[int]:
128
192
  """Calculate age from DOB in YYYY-MM-DD format"""
129
193
  if not dob:
130
194
  return None
131
195
  try:
132
196
  birth_date = datetime.strptime(dob, "%Y-%m-%d").date()
133
- if reference_date:
134
- ref_date = datetime.strptime(reference_date, "%Y-%m-%d").date()
135
- else:
136
- ref_date = date.today()
137
-
197
+ ref_date = datetime.strptime(reference_date, "%Y-%m-%d").date() if reference_date else date.today()
138
198
  age = ref_date.year - birth_date.year
139
199
  if (ref_date.month, ref_date.day) < (birth_date.month, birth_date.day):
140
200
  age -= 1
@@ -142,121 +202,355 @@ def calculate_age(dob: str, reference_date: Optional[str] = None) -> Optional[in
142
202
  except (ValueError, AttributeError):
143
203
  return None
144
204
 
145
- def get_segment_value(segment: List[str], index: int, default: Optional[str] = None) -> Optional[str]:
146
- """Safely get value from segment at given index"""
205
+
206
+ def is_new_enrollee(coverage_start_date: Optional[str], reference_date: Optional[str] = None) -> bool:
207
+ """Determine if member is new enrollee (<= 3 months since coverage start)"""
208
+ if not coverage_start_date:
209
+ return False
147
210
  try:
148
- if len(segment) > index and segment[index]:
149
- return segment[index]
150
- except (IndexError, TypeError):
151
- pass
152
- return default
211
+ start_date = datetime.strptime(coverage_start_date, "%Y-%m-%d").date()
212
+ ref_date = datetime.strptime(reference_date, "%Y-%m-%d").date() if reference_date else date.today()
213
+ months_diff = (ref_date.year - start_date.year) * 12 + (ref_date.month - start_date.month)
214
+ return months_diff <= 3
215
+ except (ValueError, AttributeError):
216
+ return False
217
+
218
+
219
+ def contains_any_keyword(text: str, keywords: set) -> bool:
220
+ """Check if text contains any of the keywords"""
221
+ text_upper = text.upper()
222
+ return any(kw in text_upper for kw in keywords)
153
223
 
154
- def parse_composite_ref_value(value: str) -> str:
155
- """Parse X12 composite element format: 'qualifier;id;...'
156
224
 
157
- X12 uses semicolons to separate sub-elements within a composite data element.
158
- Example: REF*23*9;20061234; where 9 is the ID type qualifier
225
+ def parse_race_code(raw_value: Optional[str]) -> Optional[str]:
226
+ """Parse race code from DMG05 and translate to human-readable name.
227
+
228
+ Handles formats like:
229
+ - ":RET:2135-2" -> "Hispanic or Latino"
230
+ - "2135-2" -> "Hispanic or Latino"
231
+ - "2106-3" -> "White"
159
232
 
160
233
  Args:
161
- value: Raw REF segment value (e.g., '9;20061234;' or '20061234')
234
+ raw_value: Raw race value from DMG segment
162
235
 
163
236
  Returns:
164
- The last non-empty sub-element (the actual ID)
237
+ Human-readable race/ethnicity name, or original value if not found
165
238
  """
166
- if not value:
167
- return value
239
+ if not raw_value:
240
+ return None
168
241
 
169
- if ';' in value:
170
- # Split by semicolon and filter out empty parts
171
- parts = [p for p in value.split(';') if p]
172
- return parts[-1] if parts else value
242
+ # Extract code from formats like ":RET:2135-2" or "2135-2"
243
+ code = raw_value
244
+ if ':' in raw_value:
245
+ parts = raw_value.split(':')
246
+ # Take the last non-empty part which should be the code
247
+ code = next((p for p in reversed(parts) if p), raw_value)
248
+
249
+ # Look up in mapping
250
+ mapping = _get_race_ethnicity_mapping()
251
+ return mapping.get(code, raw_value)
173
252
 
174
- return value
175
253
 
254
+ # ============================================================================
255
+ # Dual Eligibility Logic
256
+ # ============================================================================
176
257
 
177
258
  def determine_dual_status(member: MemberContext) -> str:
178
- """Intelligently derive dual eligibility code from available data
179
-
180
- Priority order:
181
- 1. Explicit dual_elgbl_cd from REF segment
182
- 2. California Medi-Cal aid code mapping
183
- 3. Medicare status code (QMB, SLMB, etc.)
184
- 4. Presence of both Medicare and Medicaid coverage
185
- 5. Default to non-dual ('00')
259
+ """Derive dual eligibility code from available data
260
+
261
+ Priority: explicit code > aid code mapping > medicare status > both coverages > default
186
262
  """
187
- # Priority 1: Explicit dual_elgbl_cd
188
- if member.dual_elgbl_cd and member.dual_elgbl_cd in VALID_DUAL_CODES:
263
+ if member.dual_elgbl_cd in VALID_DUAL_CODES:
189
264
  return member.dual_elgbl_cd
190
265
 
191
- # Priority 2: California aid code mapping
192
266
  if member.medi_cal_aid_code:
193
267
  dual_code = map_aid_code_to_dual_status(member.medi_cal_aid_code)
194
268
  if dual_code != NON_DUAL_CODE:
195
269
  return dual_code
196
270
 
197
- # Priority 3: Medicare status code
198
271
  if member.medicare_status_code:
199
272
  dual_code = map_medicare_status_to_dual_code(member.medicare_status_code)
200
273
  if dual_code != NON_DUAL_CODE:
201
274
  return dual_code
202
275
 
203
- # Priority 4: Both Medicare and Medicaid coverage present
204
276
  if member.has_medicare and (member.has_medicaid or member.medicaid_id):
205
- # Conservative: assign '08' (Other Full Dual) to ensure dual coefficients
206
- return '08'
277
+ return '08' # Other Full Dual
207
278
 
208
- # Default: Non-dual
209
279
  return NON_DUAL_CODE
210
280
 
281
+
211
282
  def classify_dual_benefit_level(dual_code: str) -> Tuple[bool, bool]:
212
- """Classify as Full Benefit Dual (FBD) or Partial Benefit Dual (PBD)
283
+ """Return (is_full_benefit_dual, is_partial_benefit_dual)"""
284
+ return dual_code in FULL_BENEFIT_DUAL_CODES, dual_code in PARTIAL_BENEFIT_DUAL_CODES
213
285
 
214
- Full Benefit Dual codes: 02, 04, 08
215
- - Uses CFA_ (Community, Full Benefit Dual, Aged) prefix
216
- - Uses CFD_ (Community, Full Benefit Dual, Disabled) prefix
217
286
 
218
- Partial Benefit Dual codes: 01, 03, 05, 06
219
- - Uses CPA_ (Community, Partial Benefit Dual, Aged) prefix
220
- - Uses CPD_ (Community, Partial Benefit Dual, Disabled) prefix
221
- """
222
- is_fbd = dual_code in FULL_BENEFIT_DUAL_CODES
223
- is_pbd = dual_code in PARTIAL_BENEFIT_DUAL_CODES
287
+ # ============================================================================
288
+ # REF Segment Parsers (CA DHCS specific composites)
289
+ # ============================================================================
224
290
 
225
- return is_fbd, is_pbd
291
+ def parse_ref_23(value: str, member: MemberContext) -> None:
292
+ """REF*23: Cin_Check_Digit;Fame_Card_Issue_Date;;..."""
293
+ member.cin_check_digit = get_composite_part(value, 0)
294
+ card_date = get_composite_part(value, 1)
295
+ if card_date and len(card_date) >= 8:
296
+ member.fame_card_issue_date = parse_date(card_date[:8])
226
297
 
227
- def is_new_enrollee(coverage_start_date: Optional[str], reference_date: Optional[str] = None) -> bool:
228
- """Determine if member is new enrollee (<= 3 months since coverage start)"""
229
- if not coverage_start_date:
230
- return False
231
298
 
232
- try:
233
- start_date = datetime.strptime(coverage_start_date, "%Y-%m-%d").date()
234
- if reference_date:
235
- ref_date = datetime.strptime(reference_date, "%Y-%m-%d").date()
236
- else:
237
- ref_date = date.today()
299
+ def parse_ref_3h(value: str, member: MemberContext) -> None:
300
+ """REF*3H: County;AID_Code;Case_Number;;..."""
301
+ member.fame_county_id = get_composite_part(value, 0)
302
+ aid_code = get_composite_part(value, 1)
303
+ if aid_code and not member.medi_cal_aid_code:
304
+ member.medi_cal_aid_code = aid_code
305
+ member.case_number = get_composite_part(value, 2)
238
306
 
239
- # Calculate months difference
240
- months_diff = (ref_date.year - start_date.year) * 12 + (ref_date.month - start_date.month)
241
307
 
242
- return months_diff <= 3
308
+ def parse_ref_6o(value: str, member: MemberContext) -> None:
309
+ """REF*6O: ?;RES-ADDR-FLAG;REAS-ADD-IND;?;RES-ZIP-DELIV-CODE;?"""
310
+ member.res_addr_flag = get_composite_part(value, 1)
311
+ member.reas_add_ind = get_composite_part(value, 2)
312
+ zip_code = get_composite_part(value, 4)
313
+ if zip_code:
314
+ member.res_zip_deliv_code = strip_leading_zeros(zip_code)
315
+
316
+
317
+ def parse_ref_dx(value: str, member: MemberContext) -> None:
318
+ """REF*DX: Fed_Contract_Number;Carrier_Code;Policy_Start;..."""
319
+ member.fed_contract_number = get_composite_part(value, 0)
320
+ carrier = get_composite_part(value, 1)
321
+ if carrier:
322
+ member.carrier_code = strip_leading_zeros(carrier)
323
+ policy_start = get_composite_part(value, 2)
324
+ if policy_start and len(policy_start) >= 8:
325
+ member.coverage_start_date = parse_date(policy_start[:8])
326
+
327
+
328
+ def parse_ref_17(value: str, member: MemberContext) -> None:
329
+ """REF*17: YYYYMM;;... (FAME redetermination date)"""
330
+ yyyymm = get_composite_part(value, 0)
331
+ if yyyymm and len(yyyymm) >= 6:
332
+ member.fame_redetermination_date = f"{yyyymm[:4]}-{yyyymm[4:6]}-01"
333
+
334
+
335
+ # ============================================================================
336
+ # Public API Functions
337
+ # ============================================================================
338
+
339
+ def enrollment_to_demographics(enrollment: EnrollmentData) -> Demographics:
340
+ """Convert EnrollmentData to Demographics model for risk calculation"""
341
+ return Demographics(
342
+ age=enrollment.age or 0,
343
+ sex=enrollment.sex or 'M',
344
+ dual_elgbl_cd=enrollment.dual_elgbl_cd,
345
+ orec=enrollment.orec or '',
346
+ crec=enrollment.crec or '',
347
+ new_enrollee=enrollment.new_enrollee,
348
+ snp=enrollment.snp,
349
+ low_income=enrollment.low_income,
350
+ lti=enrollment.lti
351
+ )
352
+
353
+
354
+ def is_losing_medicaid(enrollment: EnrollmentData, within_days: int = 90) -> bool:
355
+ """Check if member will lose Medicaid within specified days"""
356
+ if not enrollment.coverage_end_date or not enrollment.has_medicaid:
357
+ return False
358
+ try:
359
+ end_date = datetime.strptime(enrollment.coverage_end_date, "%Y-%m-%d").date()
360
+ days_until_end = (end_date - date.today()).days
361
+ return 0 <= days_until_end <= within_days
243
362
  except (ValueError, AttributeError):
244
363
  return False
245
364
 
246
- def parse_834_enrollment(segments: List[List[str]]) -> List[EnrollmentData]:
247
- """Extract enrollment and demographic data from 834 transaction
248
-
249
- California DHCS Medi-Cal 834 Structure:
250
- Loop 2000 - Member Level
251
- INS - Member Level Detail (subscriber/dependent, maintenance type)
252
- REF - Member Identifiers (0F, 1L, F6, 6P, ZZ, AB, ABB)
253
- DTP - Date Time Periods (303, 348, 349, 338)
254
- NM1 - Member Name (IL qualifier)
255
- DMG - Demographics (DOB, Sex) ***CRITICAL***
256
- HD - Health Coverage ***CRITICAL FOR DUAL STATUS***
257
- """
365
+
366
+ def is_medicaid_terminated(enrollment: EnrollmentData) -> bool:
367
+ """Check if Medicaid coverage is being terminated (maintenance type 024)"""
368
+ return enrollment.maintenance_type == '024'
369
+
370
+
371
+ def medicaid_status_summary(enrollment: EnrollmentData) -> Dict:
372
+ """Get summary of Medicaid coverage status for monitoring"""
373
+ return {
374
+ 'member_id': enrollment.member_id,
375
+ 'has_medicaid': enrollment.has_medicaid,
376
+ 'has_medicare': enrollment.has_medicare,
377
+ 'dual_status': enrollment.dual_elgbl_cd,
378
+ 'is_full_benefit_dual': enrollment.is_full_benefit_dual,
379
+ 'is_partial_benefit_dual': enrollment.is_partial_benefit_dual,
380
+ 'coverage_end_date': enrollment.coverage_end_date,
381
+ 'is_termination': is_medicaid_terminated(enrollment),
382
+ 'losing_medicaid_30d': is_losing_medicaid(enrollment, 30),
383
+ 'losing_medicaid_60d': is_losing_medicaid(enrollment, 60),
384
+ 'losing_medicaid_90d': is_losing_medicaid(enrollment, 90)
385
+ }
386
+
387
+
388
+ # ============================================================================
389
+ # Main Parsing Logic
390
+ # ============================================================================
391
+
392
+ def _process_ref_segment(qualifier: str, value: str, member: MemberContext, in_hd_loop: bool) -> None:
393
+ """Process REF segment based on qualifier"""
394
+ # HD-loop specific
395
+ if in_hd_loop and member.current_hcp and qualifier == 'CE':
396
+ member.current_hcp.aid_codes = value
397
+ # Also set primary_aid_code from position 0 if not already set
398
+ if not member.primary_aid_code:
399
+ member.primary_aid_code = get_composite_part(value, 0)
400
+ return
401
+
402
+ # Member identifiers
403
+ if qualifier == '0F' and not member.member_id:
404
+ member.member_id = value
405
+ elif qualifier == 'ZZ' and not member.member_id:
406
+ member.member_id = value
407
+ elif qualifier == '6P':
408
+ member.mbi = value
409
+ member.has_medicare = True
410
+ elif qualifier == 'F6':
411
+ member.hic = value
412
+ if not member.mbi:
413
+ member.mbi = value
414
+ member.has_medicare = True
415
+ elif qualifier == '1D':
416
+ parts = [p for p in value.split(';') if p]
417
+ member.medicaid_id = parts[-1] if parts else value
418
+ member.has_medicaid = True
419
+ elif qualifier == '23':
420
+ parse_ref_23(value, member)
421
+ member.has_medicaid = True
422
+ # California Medi-Cal
423
+ elif qualifier == 'ABB':
424
+ member.medicare_status_code = value
425
+ elif qualifier == 'AB':
426
+ member.medi_cal_aid_code = value
427
+ # CA DHCS custom
428
+ elif qualifier == '3H':
429
+ parse_ref_3h(value, member)
430
+ elif qualifier == '6O':
431
+ parse_ref_6o(value, member)
432
+ elif qualifier == 'RB':
433
+ member.primary_aid_code = value
434
+ elif qualifier == 'CE' and not member.primary_aid_code:
435
+ # Fallback: get primary_aid_code from REF*CE position 0 if not set by REF*RB
436
+ member.primary_aid_code = get_composite_part(value, 0)
437
+ elif qualifier == 'ZX' and not member.fame_county_id:
438
+ member.fame_county_id = value
439
+ elif qualifier == '17':
440
+ if in_hd_loop:
441
+ member.client_reporting_cat = value
442
+ else:
443
+ parse_ref_17(value, member)
444
+ elif qualifier == 'DX':
445
+ parse_ref_dx(value, member)
446
+ # Dual eligibility
447
+ elif qualifier == 'F5' and value in VALID_DUAL_CODES:
448
+ member.dual_elgbl_cd = value
449
+ elif qualifier == 'DY' and value in VALID_CREC_VALUES:
450
+ member.crec = value
451
+ elif qualifier == 'EJ':
452
+ member.low_income = value.upper() in ('Y', 'YES', '1', 'TRUE')
453
+
454
+
455
+ def _process_hd_segment(segment: List[str], member: MemberContext) -> None:
456
+ """Process HD (Health Coverage) segment"""
457
+ # Save previous HCP context
458
+ if member.current_hcp:
459
+ member.hcp_history.append(member.current_hcp)
460
+
461
+ member.current_hcp = HCPContext()
462
+
463
+ # Parse HCP code and status from HD04
464
+ plan_desc = get_segment_value(segment, 4, '')
465
+ if plan_desc and ';' in plan_desc:
466
+ parts = plan_desc.split(';')
467
+ if parts[0]:
468
+ hcp_code = strip_leading_zeros(parts[0])
469
+ member.current_hcp.hcp_code = hcp_code
470
+ if not member.hcp_code:
471
+ member.hcp_code = hcp_code
472
+ if len(parts) > 1 and parts[1]:
473
+ hcp_status = strip_leading_zeros(parts[1])
474
+ member.current_hcp.hcp_status = hcp_status
475
+ if not member.hcp_status:
476
+ member.hcp_status = hcp_status
477
+
478
+ # Detect coverage types from combined fields
479
+ insurance_line = get_segment_value(segment, 3, '')
480
+ insurance_type = get_segment_value(segment, 6, '')
481
+ combined = f"{insurance_line} {plan_desc} {insurance_type}"
482
+
483
+ if contains_any_keyword(combined, MEDICARE_KEYWORDS):
484
+ member.has_medicare = True
485
+ if contains_any_keyword(combined, MEDICAID_KEYWORDS):
486
+ member.has_medicaid = True
487
+ if contains_any_keyword(combined, SNP_KEYWORDS):
488
+ member.snp = True
489
+ if any(kw in combined.upper() for kw in ('D-SNP', 'DSNP', 'DUAL')):
490
+ member.has_medicare = True
491
+ member.has_medicaid = True
492
+ if contains_any_keyword(combined, LTI_KEYWORDS):
493
+ member.lti = True
494
+
495
+
496
+ def _finalize_member(member: MemberContext, source: str, report_date: str) -> EnrollmentData:
497
+ """Convert MemberContext to EnrollmentData"""
498
+ if member.current_hcp:
499
+ member.hcp_history.append(member.current_hcp)
500
+ member.current_hcp = None
501
+
502
+ age = calculate_age(member.dob)
503
+ dual_code = determine_dual_status(member)
504
+ is_fbd, is_pbd = classify_dual_benefit_level(dual_code)
505
+ new_enrollee = is_new_enrollee(member.coverage_start_date)
506
+
507
+ hcp_history = [
508
+ HCPCoveragePeriod(
509
+ start_date=hcp.start_date, end_date=hcp.end_date,
510
+ hcp_code=hcp.hcp_code, hcp_status=hcp.hcp_status, aid_codes=hcp.aid_codes
511
+ )
512
+ for hcp in member.hcp_history
513
+ ]
514
+
515
+ return EnrollmentData(
516
+ source=source, report_date=report_date,
517
+ member_id=member.member_id, mbi=member.mbi, medicaid_id=member.medicaid_id,
518
+ hic=member.hic, cin_check_digit=member.cin_check_digit,
519
+ first_name=member.first_name, last_name=member.last_name, middle_name=member.middle_name,
520
+ dob=member.dob, age=age, sex=member.sex, race=member.race,
521
+ language=member.language, death_date=member.death_date,
522
+ address_1=member.address_1, address_2=member.address_2,
523
+ city=member.city, state=member.state, zip=member.zip, phone=member.phone,
524
+ maintenance_type=member.maintenance_type,
525
+ maintenance_reason_code=member.maintenance_reason_code,
526
+ benefit_status_code=member.benefit_status_code,
527
+ coverage_start_date=member.coverage_start_date,
528
+ coverage_end_date=member.coverage_end_date,
529
+ has_medicare=member.has_medicare, has_medicaid=member.has_medicaid,
530
+ dual_elgbl_cd=dual_code, is_full_benefit_dual=is_fbd, is_partial_benefit_dual=is_pbd,
531
+ medicare_status_code=member.medicare_status_code,
532
+ medi_cal_aid_code=member.medi_cal_aid_code,
533
+ fame_county_id=member.fame_county_id, case_number=member.case_number,
534
+ fame_card_issue_date=member.fame_card_issue_date,
535
+ fame_redetermination_date=member.fame_redetermination_date,
536
+ fame_death_date=member.fame_death_date, primary_aid_code=member.primary_aid_code,
537
+ carrier_code=member.carrier_code, fed_contract_number=member.fed_contract_number,
538
+ client_reporting_cat=member.client_reporting_cat,
539
+ res_addr_flag=member.res_addr_flag, reas_add_ind=member.reas_add_ind,
540
+ res_zip_deliv_code=member.res_zip_deliv_code,
541
+ orec=member.orec, crec=member.crec, snp=member.snp,
542
+ low_income=member.low_income, lti=member.lti, new_enrollee=new_enrollee,
543
+ hcp_code=member.hcp_code, hcp_status=member.hcp_status,
544
+ amount_qualifier=member.amount_qualifier, amount=member.amount,
545
+ hcp_history=hcp_history
546
+ )
547
+
548
+
549
+ def parse_834_enrollment(segments: List[List[str]], source: str = None, report_date: str = None) -> List[EnrollmentData]:
550
+ """Extract enrollment data from 834 transaction segments"""
258
551
  enrollments = []
259
552
  member = MemberContext()
553
+ in_hd_loop = False
260
554
 
261
555
  for segment in segments:
262
556
  if len(segment) < 2:
@@ -264,217 +558,146 @@ def parse_834_enrollment(segments: List[List[str]]) -> List[EnrollmentData]:
264
558
 
265
559
  seg_id = segment[0]
266
560
 
267
- # ===== INS - Member Level Detail (Start of 2000 loop) =====
268
- if seg_id == 'INS' and len(segment) >= 3:
269
- # Save previous member before starting new one
561
+ # BGN - Source identifier
562
+ if seg_id == 'BGN' and len(segment) >= 3:
563
+ bgn_ref = get_segment_value(segment, 2)
564
+ if bgn_ref and '-' in bgn_ref:
565
+ parts = bgn_ref.split('-')
566
+ if len(parts) >= 2:
567
+ source = f"{parts[0]}-{parts[1]}"
568
+ if 'south la' in bgn_ref.lower():
569
+ source = f"SLA-{source}"
570
+
571
+ # N1*IN - Plan name (for SLA prefix)
572
+ elif seg_id == 'N1' and get_segment_value(segment, 1) == 'IN':
573
+ plan_name = get_segment_value(segment, 2, '')
574
+ if 'South LA' in plan_name and source and not source.startswith('SLA-'):
575
+ source = f"SLA-{source}"
576
+
577
+ # INS - Start of member loop
578
+ elif seg_id == 'INS' and len(segment) >= 3:
270
579
  if member.member_id or member.has_medicare or member.has_medicaid:
271
- enrollments.append(create_enrollment_data(member))
272
-
273
- # Start new member
580
+ enrollments.append(_finalize_member(member, source, report_date))
274
581
  member = MemberContext()
275
-
276
- # INS03 - Maintenance Type Code
582
+ in_hd_loop = False
277
583
  member.maintenance_type = get_segment_value(segment, 3)
278
- # 001=Change, 021=Addition, 024=Cancellation/Term, 025=Reinstatement
584
+ member.maintenance_reason_code = get_segment_value(segment, 4)
585
+ member.benefit_status_code = get_segment_value(segment, 5)
279
586
 
280
- # ===== REF - Reference Identifiers =====
587
+ # REF - Reference identifiers
281
588
  elif seg_id == 'REF' and len(segment) >= 3:
282
- qualifier = segment[1]
283
- value = segment[2] if len(segment) > 2 else None
284
-
285
- if not value:
286
- continue
287
-
288
- # Standard REF qualifiers
289
- if qualifier == '0F': # Subscriber Number
290
- if not member.member_id:
291
- member.member_id = value
292
- elif qualifier == 'ZZ': # Mutually Defined (often member ID or MBI)
293
- if not member.member_id:
294
- member.member_id = value
295
-
296
- # Medicare Identifiers
297
- elif qualifier == '6P': # Medicare MBI (new identifier)
298
- member.mbi = value
299
- member.has_medicare = True
300
- elif qualifier == 'F6': # Medicare HICN (legacy) or MBI
301
- if not member.mbi:
302
- member.mbi = value
303
- member.has_medicare = True
304
-
305
- # Medicaid Identifiers
306
- elif qualifier == '1D': # Medicaid/Recipient ID
307
- member.medicaid_id = parse_composite_ref_value(value)
308
- member.has_medicaid = True
309
- elif qualifier == '23': # Medicaid Recipient ID (alternative)
310
- if not member.medicaid_id:
311
- member.medicaid_id = parse_composite_ref_value(value)
312
- member.has_medicaid = True
313
-
314
- # California Medi-Cal Specific
315
- elif qualifier == 'ABB': # Medicare Status Code (QMB, SLMB, QI, etc.)
316
- member.medicare_status_code = value
317
- elif qualifier == 'AB': # Aid Code (California specific)
318
- member.medi_cal_aid_code = value
319
-
320
- # Custom dual eligibility indicators
321
- elif qualifier == 'F5': # Dual Eligibility Code (custom)
322
- if value in VALID_DUAL_CODES:
323
- member.dual_elgbl_cd = value
324
- elif qualifier == 'DX': # OREC (custom)
325
- if value in VALID_OREC_VALUES:
326
- member.orec = value
327
- elif qualifier == 'DY': # CREC (custom)
328
- if value in VALID_CREC_VALUES:
329
- member.crec = value
330
- elif qualifier == 'EJ': # Low Income Subsidy indicator
331
- member.low_income = (value.upper() in ['Y', 'YES', '1', 'TRUE'])
332
-
333
- # ===== NM1 - Member Name =====
334
- elif seg_id == 'NM1' and len(segment) >= 4:
335
- qualifier = segment[1]
336
-
337
- if qualifier == 'IL': # Insured or Subscriber
338
- # NM109 = Identification Code (Member ID)
339
- if len(segment) > 9:
340
- id_value = get_segment_value(segment, 9)
341
- if id_value and not member.member_id:
342
- member.member_id = id_value
343
-
344
- # ===== DMG - Demographics ***CRITICAL SEGMENT*** =====
589
+ value = get_segment_value(segment, 2)
590
+ if value:
591
+ _process_ref_segment(segment[1], value, member, in_hd_loop)
592
+
593
+ # NM1*IL - Member name
594
+ elif seg_id == 'NM1' and get_segment_value(segment, 1) == 'IL':
595
+ member.last_name = get_segment_value(segment, 3)
596
+ member.first_name = get_segment_value(segment, 4)
597
+ member.middle_name = get_segment_value(segment, 5)
598
+ if len(segment) > 9:
599
+ id_val = get_segment_value(segment, 9)
600
+ if id_val and not member.member_id:
601
+ member.member_id = id_val
602
+
603
+ # PER - Contact (phone)
604
+ elif seg_id == 'PER':
605
+ for i, val in enumerate(segment):
606
+ if val == 'TE' and i + 1 < len(segment):
607
+ member.phone = segment[i + 1]
608
+ break
609
+
610
+ # N3 - Address
611
+ elif seg_id == 'N3':
612
+ member.address_1 = get_segment_value(segment, 1)
613
+ member.address_2 = get_segment_value(segment, 2)
614
+
615
+ # N4 - City/State/Zip
616
+ elif seg_id == 'N4' and len(segment) >= 4:
617
+ city = get_segment_value(segment, 1)
618
+ state = get_segment_value(segment, 2)
619
+ # Strip state suffix from city if embedded
620
+ if city and state and city.upper().endswith(' ' + state.upper()):
621
+ city = city[:-len(state)-1].strip()
622
+ member.city = city.lower() if city else None
623
+ member.state = state.lower() if state else None
624
+ member.zip = get_segment_value(segment, 3)
625
+ # County code
626
+ if len(segment) > 6 and segment[5] == 'CY' and not member.fame_county_id:
627
+ member.fame_county_id = get_segment_value(segment, 6)
628
+
629
+ # LUI - Language
630
+ elif seg_id == 'LUI' and len(segment) >= 3:
631
+ lang_code = get_segment_value(segment, 2)
632
+ if lang_code:
633
+ member.language = LANGUAGE_CODES.get(lang_code.upper(), lang_code)
634
+
635
+ # DMG - Demographics
345
636
  elif seg_id == 'DMG' and len(segment) >= 3:
346
- # DMG02 = Date of Birth
347
637
  dob_str = get_segment_value(segment, 2)
348
638
  if dob_str:
349
639
  member.dob = parse_date(dob_str)
350
-
351
- # DMG03 = Gender Code
352
640
  sex = get_segment_value(segment, 3)
353
641
  if sex in X12_SEX_CODE_MAPPING:
354
642
  member.sex = X12_SEX_CODE_MAPPING[sex]
643
+ member.race = parse_race_code(get_segment_value(segment, 5))
644
+ death_str = get_segment_value(segment, 6)
645
+ if death_str and len(death_str) >= 8:
646
+ member.death_date = parse_date(death_str[:8])
355
647
 
356
- # ===== DTP - Date Time Periods =====
648
+ # DTP - Dates
357
649
  elif seg_id == 'DTP' and len(segment) >= 4:
358
- date_qualifier = segment[1]
359
- date_format = segment[2]
360
- date_value = segment[3] if len(segment) > 3 else None
361
-
362
- if not date_value or not date_format.endswith('D8'):
363
- continue
364
-
365
- parsed_date = parse_date(date_value[:8] if len(date_value) >= 8 else date_value)
366
-
367
- if not parsed_date:
368
- continue
369
-
370
- # Date qualifiers
371
- if date_qualifier == '348': # Benefit Begin Date
372
- member.coverage_start_date = parsed_date
373
- elif date_qualifier == '349': # Benefit End Date
374
- member.coverage_end_date = parsed_date
375
- elif date_qualifier == '338': # Medicare Part A/B Effective Date
376
- if not member.coverage_start_date:
377
- member.coverage_start_date = parsed_date
378
- member.has_medicare = True
379
-
380
- # ===== HD - Health Coverage ***CRITICAL FOR DUAL STATUS*** =====
650
+ qualifier = segment[1]
651
+ fmt = segment[2]
652
+ date_val = get_segment_value(segment, 3)
653
+ if date_val and fmt.endswith('D8'):
654
+ parsed = parse_date(date_val[:8] if len(date_val) >= 8 else date_val)
655
+ if parsed:
656
+ if in_hd_loop and member.current_hcp:
657
+ if qualifier == '348':
658
+ member.current_hcp.start_date = parsed
659
+ # Also set member-level coverage_start_date
660
+ if not member.coverage_start_date:
661
+ member.coverage_start_date = parsed
662
+ elif qualifier == '349':
663
+ member.current_hcp.end_date = parsed
664
+ # Also set member-level coverage_end_date
665
+ if not member.coverage_end_date:
666
+ member.coverage_end_date = parsed
667
+ else:
668
+ if qualifier == '348' and not member.coverage_start_date:
669
+ member.coverage_start_date = parsed
670
+ elif qualifier == '349' and not member.coverage_end_date:
671
+ member.coverage_end_date = parsed
672
+ elif qualifier == '338':
673
+ if not member.coverage_start_date:
674
+ member.coverage_start_date = parsed
675
+ member.has_medicare = True
676
+ elif qualifier == '435':
677
+ member.death_date = parsed
678
+ member.fame_death_date = parsed
679
+
680
+ # HD - Health coverage
381
681
  elif seg_id == 'HD' and len(segment) >= 4:
382
- # HD03 = Insurance Line Code
383
- insurance_line = get_segment_value(segment, 3, '').upper()
384
-
385
- # HD04 = Plan Coverage Description
386
- plan_desc = get_segment_value(segment, 4, '').upper()
387
-
388
- # HD06 = Insurance Type Code
389
- insurance_type = get_segment_value(segment, 6, '').upper()
390
-
391
- # Combine all fields for pattern matching
392
- combined = f"{insurance_line} {plan_desc} {insurance_type}"
393
-
394
- # Detect Medicare coverage
395
- if any(keyword in combined for keyword in [
396
- 'MEDICARE', 'MA', 'PART A', 'PART B', 'PART C', 'PART D',
397
- 'MEDICARE ADVANTAGE', 'MA-PD'
398
- ]):
399
- member.has_medicare = True
400
-
401
- # Detect Medicaid/Medi-Cal coverage
402
- if any(keyword in combined for keyword in [
403
- 'MEDICAID', 'MEDI-CAL', 'MEDI CAL', 'MEDIC-AID'
404
- ]):
405
- member.has_medicaid = True
406
-
407
- # Detect SNP (Special Needs Plan)
408
- if any(keyword in combined for keyword in [
409
- 'SNP', 'SPECIAL NEEDS', 'D-SNP', 'DSNP', 'DUAL ELIGIBLE SNP'
410
- ]):
411
- member.snp = True
412
- # If it's a D-SNP, they are definitely dual eligible
413
- if 'D-SNP' in combined or 'DSNP' in combined or 'DUAL' in combined:
414
- member.has_medicare = True
415
- member.has_medicaid = True
416
-
417
- # Detect LTI (Long Term Institutionalized)
418
- if any(keyword in combined for keyword in [
419
- 'LTC', 'LONG TERM CARE', 'LONG-TERM CARE', 'NURSING HOME',
420
- 'SKILLED NURSING', 'SNF', 'INSTITUTIONALIZED'
421
- ]):
422
- member.lti = True
423
-
424
- # Don't forget last member
682
+ _process_hd_segment(segment, member)
683
+ in_hd_loop = True
684
+
685
+ # AMT - Amount
686
+ elif seg_id == 'AMT' and len(segment) >= 3:
687
+ member.amount_qualifier = get_segment_value(segment, 1)
688
+ amt_val = get_segment_value(segment, 2)
689
+ if amt_val:
690
+ try:
691
+ member.amount = float(amt_val)
692
+ except ValueError:
693
+ pass
694
+
695
+ # Finalize last member
425
696
  if member.member_id or member.has_medicare or member.has_medicaid:
426
- enrollments.append(create_enrollment_data(member))
697
+ enrollments.append(_finalize_member(member, source, report_date))
427
698
 
428
699
  return enrollments
429
700
 
430
- def create_enrollment_data(member: MemberContext) -> EnrollmentData:
431
- """Convert MemberContext to EnrollmentData with risk adjustment fields"""
432
-
433
- # Calculate age
434
- age = calculate_age(member.dob) if member.dob else None
435
-
436
- # Determine dual eligibility status
437
- dual_code = determine_dual_status(member)
438
-
439
- # Classify FBD vs PBD
440
- is_fbd, is_pbd = classify_dual_benefit_level(dual_code)
441
-
442
- # Determine new enrollee status
443
- new_enrollee = is_new_enrollee(member.coverage_start_date)
444
-
445
- return EnrollmentData(
446
- # Identifiers
447
- member_id=member.member_id,
448
- mbi=member.mbi,
449
- medicaid_id=member.medicaid_id,
450
-
451
- # Demographics
452
- dob=member.dob,
453
- age=age,
454
- sex=member.sex,
455
-
456
- # Coverage tracking
457
- maintenance_type=member.maintenance_type,
458
- coverage_start_date=member.coverage_start_date,
459
- coverage_end_date=member.coverage_end_date,
460
-
461
- # Dual Eligibility
462
- has_medicare=member.has_medicare,
463
- has_medicaid=member.has_medicaid,
464
- dual_elgbl_cd=dual_code,
465
- is_full_benefit_dual=is_fbd,
466
- is_partial_benefit_dual=is_pbd,
467
- medicare_status_code=member.medicare_status_code,
468
- medi_cal_aid_code=member.medi_cal_aid_code,
469
-
470
- # Risk Adjustment
471
- orec=member.orec,
472
- crec=member.crec,
473
- snp=member.snp,
474
- low_income=member.low_income,
475
- lti=member.lti,
476
- new_enrollee=new_enrollee
477
- )
478
701
 
479
702
  def extract_enrollment_834(content: str) -> List[EnrollmentData]:
480
703
  """Main entry point for 834 parsing
@@ -483,29 +706,51 @@ def extract_enrollment_834(content: str) -> List[EnrollmentData]:
483
706
  content: Raw X12 834 transaction file content
484
707
 
485
708
  Returns:
486
- List of EnrollmentData objects with demographic and dual eligibility info
709
+ List of EnrollmentData objects
487
710
 
488
711
  Raises:
489
- ValueError: If content is empty or invalid 834 format
712
+ ValueError: If content is empty or invalid format
490
713
  """
491
714
  if not content:
492
715
  raise ValueError("Input X12 834 data cannot be empty")
493
716
 
494
- # Split content into segments
495
- segments = [seg.strip().split('*')
496
- for seg in content.split('~') if seg.strip()]
497
-
717
+ segments = [seg.strip().split('*') for seg in content.split('~') if seg.strip()]
498
718
  if not segments:
499
719
  raise ValueError("No valid segments found in 834 data")
500
720
 
501
- # Validate transaction type from GS segment
502
- transaction_type = None
721
+ # Validate 834 structure - must have ISA or ST*834 segment
722
+ segment_ids = {seg[0] for seg in segments if seg}
723
+ has_isa = 'ISA' in segment_ids
724
+ has_st_834 = any(
725
+ seg[0] == 'ST' and len(seg) > 1 and seg[1] == '834'
726
+ for seg in segments
727
+ )
728
+ if not has_isa and not has_st_834:
729
+ raise ValueError("Invalid or unsupported 834 format")
730
+
731
+ # Extract header info
732
+ source = None
733
+ report_date = None
734
+
503
735
  for segment in segments:
504
- if segment[0] == 'GS' and len(segment) > 8:
505
- transaction_type = TRANSACTION_TYPES.get(segment[8])
506
- break
736
+ seg_id = segment[0]
507
737
 
508
- if not transaction_type:
509
- raise ValueError("Invalid or unsupported 834 format (missing GS segment or wrong version)")
738
+ if seg_id == 'ISA' and len(segment) > 9:
739
+ source = get_segment_value(segment, 6)
740
+ if source:
741
+ source = source.strip()
742
+ isa_date = get_segment_value(segment, 9)
743
+ if isa_date:
744
+ report_date = parse_yymmdd(isa_date)
745
+
746
+ elif seg_id == 'GS' and len(segment) > 4:
747
+ if not source:
748
+ source = get_segment_value(segment, 2)
749
+ gs_date = get_segment_value(segment, 4)
750
+ if gs_date and len(gs_date) >= 8:
751
+ report_date = parse_date(gs_date[:8])
752
+ if len(segment) > 8 and segment[8] not in TRANSACTION_TYPES:
753
+ raise ValueError("Invalid or unsupported 834 format")
754
+ break
510
755
 
511
- return parse_834_enrollment(segments)
756
+ return parse_834_enrollment(segments, source, report_date)