mphapi 0.5.0__py3-none-any.whl → 1.11.0__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.
mphapi/claim.py CHANGED
@@ -60,6 +60,9 @@ class Provider(BaseModel):
60
60
  npi: str
61
61
  """National Provider Identifier of the provider (from NM109, required)"""
62
62
 
63
+ ccn: Optional[str] = None
64
+ """CMS Certification Number (optional)"""
65
+
63
66
  provider_tax_id: Annotated[Optional[str], field_name("providerTaxID")] = None
64
67
  """City of the provider (from N401, highly recommended)"""
65
68
 
@@ -195,8 +198,6 @@ class Service(BaseModel):
195
198
  procedure_code: Optional[str] = None
196
199
  """Procedure code (from SV101_02 / SV202_02)"""
197
200
 
198
- hipps_code: Optional[str] = None
199
-
200
201
  procedure_modifiers: Optional[list[str]] = None
201
202
  """Procedure modifiers (from SV101_03, 4, 5, 6 / SV202_03, 4, 5, 6)"""
202
203
 
@@ -334,12 +335,6 @@ class RateSheetService(BaseModel):
334
335
  procedure_modifiers: Optional[list[str]] = None
335
336
  """Procedure modifiers (from SV101_03, 4, 5, 6 / SV202_03, 4, 5, 6)"""
336
337
 
337
- billed_amount: Optional[float] = None
338
- """Billed charge for the service (from SV102 / SV203)"""
339
-
340
- allowed_amount: Optional[float] = None
341
- """Plan allowed amount for the service (non-EDI)"""
342
-
343
338
 
344
339
  class RateSheet(BaseModel):
345
340
  npi: str
@@ -375,14 +370,5 @@ class RateSheet(BaseModel):
375
370
  drg: Optional[str] = None
376
371
  """Diagnosis Related Group for inpatient services (from HI DR)"""
377
372
 
378
- billed_amount: Optional[float] = None
379
- """Billed amount from provider (from CLM02)"""
380
-
381
- allowed_amount: Optional[float] = None
382
- """Amount allowed by the plan for payment. Both member and plan responsibility (non-EDI)"""
383
-
384
- paid_amount: Optional[float] = None
385
- """Amount paid by the plan for the claim (non-EDI)"""
386
-
387
373
  services: Optional[list[RateSheetService]] = None
388
374
  """One or more services provided to the patient (from LX loop)"""
mphapi/client.py CHANGED
@@ -1,11 +1,12 @@
1
1
  import urllib.parse
2
- from typing import Any, Mapping, Sequence
2
+ from typing import Annotated, Any, Mapping, Optional, Sequence
3
3
 
4
4
  import requests
5
5
  from pydantic import BaseModel, StrictBool, TypeAdapter
6
6
 
7
7
  from .claim import Claim, RateSheet
8
- from .pricing import Pricing
8
+ from .credentials import Credentials, get_credentials
9
+ from .fields import camel_case_model_config, field_name
9
10
  from .response import Response, Responses
10
11
 
11
12
  Header = Mapping[str, str | bytes | None]
@@ -14,71 +15,139 @@ Header = Mapping[str, str | bytes | None]
14
15
  class PriceConfig(BaseModel):
15
16
  """PriceConfig is used to configure the behavior of the pricing API"""
16
17
 
17
- price_zero_billed: StrictBool
18
+ model_config = camel_case_model_config
19
+
20
+ contract_ruleset: Optional[str] = None
21
+ """set to the name of the ruleset to use for contract pricing"""
22
+
23
+ price_zero_billed: Optional[StrictBool] = False
18
24
  """set to true to price claims with zero billed amounts (default is false)"""
19
25
 
20
- is_commercial: StrictBool
21
- """set to true to use commercial code crosswalks"""
26
+ is_commercial: Optional[StrictBool] = False
27
+ """set to true to crosswalk codes from commercial codes Medicare won't pay for to substitute codes they do pay for (e.g. 99201 to G0463)"""
22
28
 
23
- disable_cost_based_reimbursement: StrictBool
24
- """by default, the API will use cost-based reimbursement for MAC priced line-items. This is the best estimate we have for this proprietary pricing"""
29
+ disable_cost_based_reimbursement: Optional[StrictBool] = False
30
+ """set to true to disable cost-based reimbursement for line items paid as a percent of cost"""
25
31
 
26
- use_commercial_synthetic_for_not_allowed: StrictBool
32
+ use_commercial_synthetic_for_not_allowed: Optional[StrictBool] = False
27
33
  """set to true to use a synthetic Medicare price for line-items that are not allowed by Medicare"""
28
34
 
29
- use_drg_from_grouper: StrictBool
35
+ use_drg_from_grouper: Annotated[
36
+ Optional[StrictBool], field_name("useDRGFromGrouper")
37
+ ] = False
30
38
  """set to true to always use the DRG from the inpatient grouper"""
31
39
 
32
- use_best_drg_price: StrictBool
40
+ use_best_drg_price: Annotated[StrictBool, field_name("useBestDRGPrice")]
33
41
  """set to true to use the best DRG price between the price on the claim and the price from the grouper"""
34
42
 
35
- override_threshold: float
43
+ override_threshold: Optional[float] = 0
36
44
  """set to a value greater than 0 to allow the pricer flexibility to override NCCI edits and other overridable errors and return a price"""
37
45
 
38
- include_edits: StrictBool
46
+ include_edits: Optional[StrictBool] = False
39
47
  """set to true to include edit details in the response"""
40
48
 
41
- continue_on_edit_fail: StrictBool
49
+ continue_on_edit_fail: Optional[StrictBool] = False
42
50
  """set to true to continue to price the claim even if there are edit failures"""
43
51
 
44
- continue_on_provider_match_fail: StrictBool
52
+ continue_on_provider_match_fail: Optional[StrictBool] = False
45
53
  """set to true to continue with a average provider for the geographic area if the provider cannot be matched"""
46
54
 
47
- disable_machine_learning_estimates: StrictBool
55
+ disable_machine_learning_estimates: Optional[StrictBool] = False
48
56
  """set to true to disable machine learning estimates (applies to estimates only)"""
49
57
 
58
+ assume_impossible_anesthesia_units_are_minutes: Optional[StrictBool] = False
59
+ """set to true to divide impossible anesthesia units by 15 (max of 96 anesthesia units per day) (default is false)"""
60
+
61
+ fallback_to_max_anesthesia_units_per_day: Optional[StrictBool] = False
62
+ """set to true to fallback to the maximum anesthesia units per day (default is false which will error if there are more than 96 anesthesia units per day)"""
63
+
64
+ allow_partial_results: Optional[StrictBool] = False
65
+ """set to true to return partially repriced claims. This can be useful to get pricing on non-erroring line items, but should be used with caution"""
66
+
67
+
68
+ # It may be a bit jarring to see these imports not at the top of the file. This is
69
+ # intentional as `.pricing` depends on `PriceConfig`.
70
+ from .pricing import ClaimStatus # noqa: E402
71
+ from .pricing import Pricing # noqa: E402
72
+
50
73
 
51
74
  class Client:
52
- url: str
75
+ api_url: str
53
76
  headers: Header
54
77
 
55
- def __init__(self, apiKey: str, isTest: bool = False):
56
- if isTest:
57
- self.url = "https://api-test.myprice.health"
78
+ def __init__(
79
+ self,
80
+ apiKey: str,
81
+ isTest: bool = False,
82
+ api_url: str | None = None,
83
+ app_url: str | None = None,
84
+ app_api_key: str | None = None,
85
+ app_referer: str | None = None,
86
+ app_credentials: Credentials | None = None,
87
+ ):
88
+ if api_url is None:
89
+ if isTest:
90
+ self.api_url = "https://api-test.myprice.health"
91
+ else:
92
+ self.api_url = "https://api.myprice.health"
93
+ else:
94
+ self.api_url = api_url
95
+
96
+ if app_url is None:
97
+ if isTest:
98
+ self.app_url = "https://app-test.myprice.health"
99
+ else:
100
+ self.app_url = "https://app.myprice.health"
58
101
  else:
59
- self.url = "https://api.myprice.health"
102
+ self.app_url = app_url
103
+
104
+ if (app_url is None) != (app_api_key is None) or (app_api_key is None) != (
105
+ app_referer is None
106
+ ):
107
+ raise Exception(
108
+ "app_url, app_api_key, and app_referer must be set in tandem"
109
+ )
110
+
111
+ self.app_api_key = app_api_key
112
+ self.app_credentials = app_credentials
113
+ self.app_referer = app_referer
114
+
115
+ if (
116
+ self.app_credentials is None
117
+ and app_api_key is not None
118
+ and app_referer is not None
119
+ ):
120
+ self.app_credentials = get_credentials(app_api_key, app_referer)
60
121
 
61
122
  self.headers = {"x-api-key": apiKey}
62
123
 
124
+ def _get_id_token(self) -> str:
125
+ if self.app_credentials is None:
126
+ raise Exception("App credentials must be set to run this!")
127
+
128
+ refreshed = self.app_credentials.refresh_if_needed()
129
+ if refreshed is not None:
130
+ self.app_credentials = refreshed
131
+
132
+ return self.app_credentials.id_token
133
+
63
134
  def _do_request(
64
135
  self,
65
- path: str,
136
+ url: str,
66
137
  json: Any | None,
67
138
  method: str = "POST",
68
139
  headers: Header = {},
69
140
  ) -> requests.Response:
70
141
  return requests.request(
71
142
  method,
72
- urllib.parse.urljoin(self.url, path),
143
+ url,
73
144
  json=json,
74
145
  headers={**self.headers, **headers},
75
146
  )
76
147
 
77
- def _receive_response[
78
- Model: BaseModel
79
- ](
148
+ def _receive_response[Model: BaseModel](
80
149
  self,
81
- path: str,
150
+ url: str,
82
151
  body: BaseModel,
83
152
  response_model: type[Model],
84
153
  method: str = "POST",
@@ -93,7 +162,7 @@ class Client:
93
162
  """
94
163
 
95
164
  response = self._do_request(
96
- path,
165
+ url,
97
166
  body.model_dump(mode="json", by_alias=True, exclude_none=True),
98
167
  method,
99
168
  headers,
@@ -105,11 +174,44 @@ class Client:
105
174
  .result()
106
175
  )
107
176
 
108
- def _receive_responses[
109
- Model: BaseModel
110
- ](
177
+ def _receive_api_response[Model: BaseModel](
178
+ self,
179
+ url: str,
180
+ body: BaseModel,
181
+ response_model: type[Model],
182
+ method: str = "POST",
183
+ headers: Header = {},
184
+ ) -> Model:
185
+ return self._receive_response(
186
+ urllib.parse.urljoin(self.api_url, url),
187
+ body,
188
+ response_model,
189
+ method,
190
+ headers,
191
+ )
192
+
193
+ def _receive_app_response[Model: BaseModel](
194
+ self,
195
+ url: str,
196
+ body: BaseModel,
197
+ response_model: type[Model],
198
+ method: str = "POST",
199
+ headers: Header = {},
200
+ ) -> Model:
201
+ id_token = self._get_id_token()
202
+
203
+ # TODO: Handle refreshing revoked credentials. It's unclear what the error will be unfortunately.
204
+ return self._receive_response(
205
+ urllib.parse.urljoin(self.app_url, url),
206
+ body,
207
+ response_model,
208
+ method,
209
+ {"Authorization": f"Bearer {id_token}", **headers},
210
+ )
211
+
212
+ def _receive_responses[Model: BaseModel](
111
213
  self,
112
- path: str,
214
+ url: str,
113
215
  body: Sequence[BaseModel],
114
216
  response_model: type[Model],
115
217
  method: str = "POST",
@@ -124,7 +226,7 @@ class Client:
124
226
  """
125
227
 
126
228
  response = self._do_request(
127
- path,
229
+ url,
128
230
  TypeAdapter(type(body)).dump_python(
129
231
  body, mode="json", by_alias=True, exclude_none=True
130
232
  ),
@@ -138,6 +240,41 @@ class Client:
138
240
  .results()
139
241
  )
140
242
 
243
+ def _receive_api_responses[Model: BaseModel](
244
+ self,
245
+ url: str,
246
+ body: Sequence[BaseModel],
247
+ response_model: type[Model],
248
+ method: str = "POST",
249
+ headers: Header = {},
250
+ ) -> list[Model]:
251
+ return self._receive_responses(
252
+ urllib.parse.urljoin(self.api_url, url),
253
+ body,
254
+ response_model,
255
+ method,
256
+ headers,
257
+ )
258
+
259
+ def _receive_app_responses[Model: BaseModel](
260
+ self,
261
+ url: str,
262
+ body: Sequence[BaseModel],
263
+ response_model: type[Model],
264
+ method: str = "POST",
265
+ headers: Header = {},
266
+ ) -> list[Model]:
267
+ id_token = self._get_id_token()
268
+
269
+ # TODO: Handle refreshing revoked credentials. It's unclear what the error will be unfortunately.
270
+ return self._receive_responses(
271
+ urllib.parse.urljoin(self.app_url, url),
272
+ body,
273
+ response_model,
274
+ method,
275
+ {"Authorization": f"Bearer {id_token}", **headers},
276
+ )
277
+
141
278
  def estimate_rate_sheet(self, *inputs: RateSheet) -> list[Pricing]:
142
279
  """
143
280
  Raises:
@@ -147,7 +284,7 @@ class Client:
147
284
  The error returned when the api returns an error.
148
285
  """
149
286
 
150
- return self._receive_responses(
287
+ return self._receive_api_responses(
151
288
  "/v1/medicare/estimate/rate-sheet",
152
289
  inputs,
153
290
  Pricing,
@@ -162,7 +299,7 @@ class Client:
162
299
  The error returned when the api returns an error.
163
300
  """
164
301
 
165
- return self._receive_responses(
302
+ return self._receive_api_responses(
166
303
  "/v1/medicare/estimate/claims",
167
304
  inputs,
168
305
  Pricing,
@@ -179,7 +316,7 @@ class Client:
179
316
  """
180
317
 
181
318
  return self._receive_response(
182
- "/v1/medicare/price/claim",
319
+ urllib.parse.urljoin(self.api_url, "/v1/medicare/price/claim"),
183
320
  input,
184
321
  Pricing,
185
322
  headers=self._get_price_headers(config),
@@ -194,7 +331,7 @@ class Client:
194
331
  The error returned when the api returns an error.
195
332
  """
196
333
 
197
- return self._receive_responses(
334
+ return self._receive_api_responses(
198
335
  "/v1/medicare/price/claims",
199
336
  input,
200
337
  Pricing,
@@ -215,7 +352,7 @@ class Client:
215
352
  if config.use_commercial_synthetic_for_not_allowed:
216
353
  headers["use-commercial-synthetic-for-not-allowed"] = "true"
217
354
 
218
- if config.override_threshold > 0:
355
+ if config.override_threshold is not None and config.override_threshold > 0:
219
356
  headers["override-threshold"] = str(config.override_threshold)
220
357
 
221
358
  if config.include_edits:
@@ -237,3 +374,10 @@ class Client:
237
374
  headers["disable-machine-learning-estimates"] = "true"
238
375
 
239
376
  return headers
377
+
378
+ def insert_claim_status(self, claim_id: str, claim_status: ClaimStatus) -> None:
379
+ self._receive_app_response(
380
+ f"/v1/claim/{claim_id}/status",
381
+ claim_status,
382
+ BaseModel,
383
+ )
mphapi/client_test.py ADDED
@@ -0,0 +1,114 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ # This import annoys Pylance for some reason.
9
+ from pytest_snapshot.plugin import Snapshot # type: ignore
10
+
11
+ from .client import Claim, Client, PriceConfig
12
+ from .credentials import Credentials, sign_in
13
+ from .env import load_env
14
+ from .pricing import ClaimStatus, PricedService, Pricing, status_new
15
+ from .response import ResponseError
16
+
17
+
18
+ @pytest.fixture(autouse=True)
19
+ def run_around_tests():
20
+ load_env()
21
+
22
+
23
+ def test_client(snapshot: Snapshot):
24
+ api_key = os.getenv("MPH_API_KEY")
25
+ if api_key is None:
26
+ raise EnvironmentError("MPH_API_KEY must be set")
27
+
28
+ api_url = os.getenv("API_URL")
29
+ app_url = os.getenv("APP_URL")
30
+ app_api_key = os.getenv("FIREBASE_API_KEY")
31
+ if app_api_key is None:
32
+ raise Exception("FIREBASE_API_KEY must be set")
33
+
34
+ app_referer = os.getenv("FIREBASE_REFERER")
35
+ if app_referer is None:
36
+ raise Exception("FIREBASE_REFERER must be set")
37
+
38
+ app_credentials = Credentials(
39
+ api_key=app_api_key,
40
+ referer=app_referer,
41
+ credentials_path=Path("fake-credentials-path"),
42
+ email="test-user@mypricehealth.com",
43
+ # An id token with some minimal user info.
44
+ # Will not work if tested against a service that expects a real id token.
45
+ id_token="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImFhYSI6dHJ1ZX0.eyJpc3MiOiJ0ZXN0IGlzc3VlciIsImVtYWlsIjoidGVzdC11c2VyQG15cHJpY2VoZWFsdGguY29tIiwic3ViIjoidGVzdCBzdWJqZWN0IiwiYXVkIjoidGVzdCBhdWRpZW5jZSIsImV4cCI6MCwiaWF0IjowLCJpZCI6IjEyMzQ1Njc4OTAiLCJyb2xlcyI6WyJBZG1pbiJdfQ.iCx0YI9L9qo7KCkIq1w58C0mBdiCDhikPZsM3Mx78j11Ln3WxWpAnm0IomIRfrDVU1JczW8OjSj5os7igp9fYufZwObLV5N5eDd9LmYtXs2EcO8EFHcM7HeHx6lnkT1GxX-4J_946WbIdHYnpLyoXLZF_MZNfEwsN1UBG6YdaRcwfhF9vJHt6YM7-IoOcHvdLiEWv06Y9eC0v--_R_x8GwjSrE0Z9EyZw3hMz94QZ5VgUf8e89NexeVGIoD4pUOHuYmNIx1ca_UTIOg81exhhnh4g190jUSer5582YJc5Hx7gts3DyizRmAK8Glcwhnc7WKvZDQaSjtbHzIARnUKtw",
46
+ refresh_token="fake-refresh-token",
47
+ expires_at=sys.float_info.max,
48
+ )
49
+
50
+ client = Client(
51
+ api_key,
52
+ api_url=api_url,
53
+ app_url=app_url,
54
+ app_api_key=app_api_key,
55
+ app_referer=app_referer,
56
+ app_credentials=app_credentials,
57
+ )
58
+
59
+ config = PriceConfig(
60
+ is_commercial=True,
61
+ disable_cost_based_reimbursement=False,
62
+ use_commercial_synthetic_for_not_allowed=True,
63
+ use_drg_from_grouper=False,
64
+ use_best_drg_price=True,
65
+ override_threshold=300,
66
+ include_edits=True,
67
+ )
68
+
69
+ tests = ["hcfa", "inpatient", "outpatient"]
70
+
71
+ for test in tests:
72
+ with open(f"testdata/{test}.json", "r") as f:
73
+ data = json.load(f)
74
+
75
+ claim = Claim.model_validate(data)
76
+ pricing = client.price(config, claim)
77
+
78
+ snapshot.assert_match(pricing.model_dump_json(indent=4), f"{test}.json")
79
+
80
+ try:
81
+ client.insert_claim_status(
82
+ "123",
83
+ ClaimStatus(
84
+ step=status_new.step,
85
+ status=status_new.status,
86
+ pricing=Pricing(services=[PricedService(line_number="6789")]),
87
+ ),
88
+ )
89
+ except ResponseError as response_error:
90
+ # The claim and line item won't exist in the database
91
+ assert (
92
+ response_error.detail
93
+ == "expected to insert 1 line item repricing rows but inserted 0"
94
+ )
95
+
96
+ pass
97
+
98
+
99
+ def test_signin():
100
+ app_api_key = os.getenv("FIREBASE_API_KEY")
101
+ if app_api_key is None:
102
+ raise Exception("FIREBASE_API_KEY must be set")
103
+
104
+ test_user = os.getenv("FIREBASE_TEST_USER")
105
+ if test_user is None:
106
+ pytest.skip("Skipping because FIREBASE_TEST_USER is not set")
107
+
108
+ test_password = os.getenv("FIREBASE_TEST_PASSWORD")
109
+ if test_password is None:
110
+ pytest.skip("Skipping because FIREBASE_TEST_PASSWORD is not set")
111
+
112
+ credentials = sign_in(app_api_key, test_user, test_password)
113
+
114
+ assert credentials.email == test_user