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