canvas 0.63.0__py3-none-any.whl → 0.89.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.
- {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/METADATA +4 -1
- {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/RECORD +184 -98
- {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/WHEEL +1 -1
- canvas_cli/apps/emit/event_fixtures/UNKNOWN.ndjson +1 -0
- canvas_cli/apps/logs/logs.py +386 -22
- canvas_cli/main.py +3 -1
- canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/tests/test_models.py +46 -4
- canvas_cli/utils/context/context.py +13 -13
- canvas_cli/utils/validators/manifest_schema.py +26 -1
- canvas_generated/messages/effects_pb2.py +5 -5
- canvas_generated/messages/effects_pb2.pyi +108 -2
- canvas_generated/messages/events_pb2.py +6 -6
- canvas_generated/messages/events_pb2.pyi +282 -2
- canvas_sdk/clients/__init__.py +1 -0
- canvas_sdk/clients/llms/__init__.py +17 -0
- canvas_sdk/clients/llms/libraries/__init__.py +11 -0
- canvas_sdk/clients/llms/libraries/llm_anthropic.py +87 -0
- canvas_sdk/clients/llms/libraries/llm_api.py +143 -0
- canvas_sdk/clients/llms/libraries/llm_google.py +92 -0
- canvas_sdk/clients/llms/libraries/llm_openai.py +98 -0
- canvas_sdk/clients/llms/structures/__init__.py +9 -0
- canvas_sdk/clients/llms/structures/llm_response.py +33 -0
- canvas_sdk/clients/llms/structures/llm_tokens.py +53 -0
- canvas_sdk/clients/llms/structures/llm_turn.py +47 -0
- canvas_sdk/clients/llms/structures/settings/__init__.py +13 -0
- canvas_sdk/clients/llms/structures/settings/llm_settings.py +27 -0
- canvas_sdk/clients/llms/structures/settings/llm_settings_anthropic.py +43 -0
- canvas_sdk/clients/llms/structures/settings/llm_settings_gemini.py +40 -0
- canvas_sdk/clients/llms/structures/settings/llm_settings_gpt4.py +40 -0
- canvas_sdk/clients/llms/structures/settings/llm_settings_gpt5.py +48 -0
- canvas_sdk/clients/third_party.py +3 -0
- canvas_sdk/commands/__init__.py +12 -0
- canvas_sdk/commands/base.py +33 -2
- canvas_sdk/commands/commands/adjust_prescription.py +4 -0
- canvas_sdk/commands/commands/custom_command.py +86 -0
- canvas_sdk/commands/commands/family_history.py +17 -1
- canvas_sdk/commands/commands/immunization_statement.py +42 -2
- canvas_sdk/commands/commands/medication_statement.py +16 -1
- canvas_sdk/commands/commands/past_surgical_history.py +16 -1
- canvas_sdk/commands/commands/perform.py +18 -1
- canvas_sdk/commands/commands/prescribe.py +8 -9
- canvas_sdk/commands/commands/refill.py +5 -5
- canvas_sdk/commands/commands/resolve_condition.py +5 -5
- canvas_sdk/commands/commands/review/__init__.py +3 -0
- canvas_sdk/commands/commands/review/base.py +72 -0
- canvas_sdk/commands/commands/review/imaging.py +13 -0
- canvas_sdk/commands/commands/review/lab.py +13 -0
- canvas_sdk/commands/commands/review/referral.py +13 -0
- canvas_sdk/commands/commands/review/uncategorized_document.py +13 -0
- canvas_sdk/commands/validation.py +43 -0
- canvas_sdk/effects/batch_originate.py +22 -0
- canvas_sdk/effects/calendar/__init__.py +13 -3
- canvas_sdk/effects/calendar/{create_calendar.py → calendar.py} +19 -5
- canvas_sdk/effects/calendar/event.py +172 -0
- canvas_sdk/effects/claim_label.py +93 -0
- canvas_sdk/effects/claim_line_item.py +47 -0
- canvas_sdk/effects/claim_queue.py +49 -0
- canvas_sdk/effects/fax/__init__.py +3 -0
- canvas_sdk/effects/fax/base.py +77 -0
- canvas_sdk/effects/fax/note.py +42 -0
- canvas_sdk/effects/metadata.py +15 -1
- canvas_sdk/effects/note/__init__.py +8 -1
- canvas_sdk/effects/note/appointment.py +135 -7
- canvas_sdk/effects/note/base.py +17 -0
- canvas_sdk/effects/note/message.py +22 -14
- canvas_sdk/effects/note/note.py +150 -1
- canvas_sdk/effects/observation/__init__.py +11 -0
- canvas_sdk/effects/observation/base.py +206 -0
- canvas_sdk/effects/patient/__init__.py +2 -0
- canvas_sdk/effects/patient/base.py +8 -0
- canvas_sdk/effects/payment/__init__.py +11 -0
- canvas_sdk/effects/payment/base.py +355 -0
- canvas_sdk/effects/payment/post_claim_payment.py +49 -0
- canvas_sdk/effects/send_contact_verification.py +42 -0
- canvas_sdk/effects/task/__init__.py +2 -1
- canvas_sdk/effects/task/task.py +30 -0
- canvas_sdk/effects/validation/__init__.py +3 -0
- canvas_sdk/effects/validation/base.py +92 -0
- canvas_sdk/events/base.py +15 -0
- canvas_sdk/handlers/application.py +7 -7
- canvas_sdk/handlers/simple_api/api.py +1 -4
- canvas_sdk/handlers/simple_api/websocket.py +1 -4
- canvas_sdk/handlers/utils.py +14 -0
- canvas_sdk/questionnaires/utils.py +1 -0
- canvas_sdk/templates/utils.py +17 -4
- canvas_sdk/test_utils/factories/FACTORY_GUIDE.md +362 -0
- canvas_sdk/test_utils/factories/__init__.py +115 -0
- canvas_sdk/test_utils/factories/calendar.py +24 -0
- canvas_sdk/test_utils/factories/claim.py +81 -0
- canvas_sdk/test_utils/factories/claim_diagnosis_code.py +16 -0
- canvas_sdk/test_utils/factories/coverage.py +17 -0
- canvas_sdk/test_utils/factories/imaging.py +74 -0
- canvas_sdk/test_utils/factories/lab.py +192 -0
- canvas_sdk/test_utils/factories/medication_history.py +75 -0
- canvas_sdk/test_utils/factories/note.py +52 -0
- canvas_sdk/test_utils/factories/organization.py +50 -0
- canvas_sdk/test_utils/factories/practicelocation.py +88 -0
- canvas_sdk/test_utils/factories/referral.py +81 -0
- canvas_sdk/test_utils/factories/staff.py +111 -0
- canvas_sdk/test_utils/factories/task.py +66 -0
- canvas_sdk/test_utils/factories/uncategorized_clinical_document.py +48 -0
- canvas_sdk/utils/metrics.py +4 -1
- canvas_sdk/v1/data/__init__.py +66 -7
- canvas_sdk/v1/data/allergy_intolerance.py +5 -11
- canvas_sdk/v1/data/appointment.py +18 -4
- canvas_sdk/v1/data/assessment.py +2 -12
- canvas_sdk/v1/data/banner_alert.py +2 -4
- canvas_sdk/v1/data/base.py +53 -14
- canvas_sdk/v1/data/billing.py +8 -11
- canvas_sdk/v1/data/calendar.py +64 -0
- canvas_sdk/v1/data/care_team.py +4 -10
- canvas_sdk/v1/data/claim.py +172 -66
- canvas_sdk/v1/data/claim_diagnosis_code.py +19 -0
- canvas_sdk/v1/data/claim_line_item.py +2 -5
- canvas_sdk/v1/data/coding.py +19 -0
- canvas_sdk/v1/data/command.py +2 -4
- canvas_sdk/v1/data/common.py +10 -0
- canvas_sdk/v1/data/compound_medication.py +3 -4
- canvas_sdk/v1/data/condition.py +4 -9
- canvas_sdk/v1/data/coverage.py +66 -26
- canvas_sdk/v1/data/detected_issue.py +20 -20
- canvas_sdk/v1/data/device.py +2 -14
- canvas_sdk/v1/data/discount.py +2 -5
- canvas_sdk/v1/data/encounter.py +44 -0
- canvas_sdk/v1/data/facility.py +1 -0
- canvas_sdk/v1/data/goal.py +2 -14
- canvas_sdk/v1/data/imaging.py +4 -30
- canvas_sdk/v1/data/immunization.py +7 -15
- canvas_sdk/v1/data/lab.py +12 -65
- canvas_sdk/v1/data/line_item_transaction.py +2 -5
- canvas_sdk/v1/data/medication.py +3 -8
- canvas_sdk/v1/data/medication_history.py +142 -0
- canvas_sdk/v1/data/medication_statement.py +41 -0
- canvas_sdk/v1/data/message.py +4 -8
- canvas_sdk/v1/data/note.py +37 -38
- canvas_sdk/v1/data/observation.py +9 -36
- canvas_sdk/v1/data/organization.py +70 -9
- canvas_sdk/v1/data/patient.py +8 -12
- canvas_sdk/v1/data/patient_consent.py +4 -14
- canvas_sdk/v1/data/payment_collection.py +2 -5
- canvas_sdk/v1/data/posting.py +3 -9
- canvas_sdk/v1/data/practicelocation.py +66 -7
- canvas_sdk/v1/data/protocol_override.py +3 -4
- canvas_sdk/v1/data/protocol_result.py +3 -3
- canvas_sdk/v1/data/questionnaire.py +10 -26
- canvas_sdk/v1/data/reason_for_visit.py +2 -6
- canvas_sdk/v1/data/referral.py +41 -17
- canvas_sdk/v1/data/staff.py +34 -26
- canvas_sdk/v1/data/stop_medication_event.py +27 -0
- canvas_sdk/v1/data/task.py +30 -11
- canvas_sdk/v1/data/team.py +2 -4
- canvas_sdk/v1/data/uncategorized_clinical_document.py +84 -0
- canvas_sdk/v1/data/user.py +14 -0
- canvas_sdk/v1/data/utils.py +5 -0
- canvas_sdk/value_set/v2026/__init__.py +1 -0
- canvas_sdk/value_set/v2026/adverse_event.py +157 -0
- canvas_sdk/value_set/v2026/allergy.py +116 -0
- canvas_sdk/value_set/v2026/assessment.py +466 -0
- canvas_sdk/value_set/v2026/communication.py +496 -0
- canvas_sdk/value_set/v2026/condition.py +52934 -0
- canvas_sdk/value_set/v2026/device.py +315 -0
- canvas_sdk/value_set/v2026/diagnostic_study.py +5243 -0
- canvas_sdk/value_set/v2026/encounter.py +2714 -0
- canvas_sdk/value_set/v2026/immunization.py +297 -0
- canvas_sdk/value_set/v2026/individual_characteristic.py +339 -0
- canvas_sdk/value_set/v2026/intervention.py +1703 -0
- canvas_sdk/value_set/v2026/laboratory_test.py +1831 -0
- canvas_sdk/value_set/v2026/medication.py +8218 -0
- canvas_sdk/value_set/v2026/no_qdm_category_assigned.py +26493 -0
- canvas_sdk/value_set/v2026/physical_exam.py +342 -0
- canvas_sdk/value_set/v2026/procedure.py +27869 -0
- canvas_sdk/value_set/v2026/symptom.py +625 -0
- logger/logger.py +30 -31
- logger/logstash.py +282 -0
- logger/pubsub.py +26 -0
- plugin_runner/allowed-module-imports.json +940 -9
- plugin_runner/generate_allowed_imports.py +1 -0
- plugin_runner/installation.py +2 -2
- plugin_runner/plugin_runner.py +21 -24
- plugin_runner/sandbox.py +34 -0
- protobufs/canvas_generated/messages/effects.proto +65 -0
- protobufs/canvas_generated/messages/events.proto +150 -51
- settings.py +27 -11
- canvas_sdk/effects/calendar/create_event.py +0 -43
- {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import json
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from pydantic_core import InitErrorDetails
|
|
8
|
+
|
|
9
|
+
from canvas_generated.messages.effects_pb2 import Effect
|
|
10
|
+
from canvas_sdk.base import TrackableFieldsModel
|
|
11
|
+
from canvas_sdk.v1.data.observation import Observation as ObservationModel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class CodingData:
|
|
16
|
+
"""A class representing coding data for observations, components, and values."""
|
|
17
|
+
|
|
18
|
+
code: str
|
|
19
|
+
display: str
|
|
20
|
+
system: str
|
|
21
|
+
version: str = ""
|
|
22
|
+
user_selected: bool = False
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict[str, Any]:
|
|
25
|
+
"""Convert the coding to a dictionary."""
|
|
26
|
+
return {
|
|
27
|
+
"code": self.code,
|
|
28
|
+
"display": self.display,
|
|
29
|
+
"system": self.system,
|
|
30
|
+
"version": self.version,
|
|
31
|
+
"user_selected": self.user_selected,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ObservationComponentData:
|
|
37
|
+
"""A class representing observation component data."""
|
|
38
|
+
|
|
39
|
+
value_quantity: str
|
|
40
|
+
value_quantity_unit: str
|
|
41
|
+
name: str
|
|
42
|
+
codings: list[CodingData] | None = None
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, Any]:
|
|
45
|
+
"""Convert the component to a dictionary."""
|
|
46
|
+
return {
|
|
47
|
+
"value_quantity": self.value_quantity,
|
|
48
|
+
"value_quantity_unit": self.value_quantity_unit,
|
|
49
|
+
"name": self.name,
|
|
50
|
+
"codings": ([c.to_dict() for c in self.codings] if self.codings is not None else None),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Observation(TrackableFieldsModel):
|
|
55
|
+
"""Effect to create or update an Observation record."""
|
|
56
|
+
|
|
57
|
+
class Meta:
|
|
58
|
+
effect_type = "OBSERVATION"
|
|
59
|
+
|
|
60
|
+
observation_id: str | UUID | None = None # For updates
|
|
61
|
+
patient_id: str | None = None
|
|
62
|
+
is_member_of_id: str | UUID | None = None # Reference to parent Observation
|
|
63
|
+
category: str | list[str] | None = None
|
|
64
|
+
units: str | None = None
|
|
65
|
+
value: str | None = None
|
|
66
|
+
note_id: int | None = None
|
|
67
|
+
name: str | None = None
|
|
68
|
+
effective_datetime: datetime.datetime | None = None
|
|
69
|
+
|
|
70
|
+
# Nested related objects
|
|
71
|
+
codings: list[CodingData] | None = None
|
|
72
|
+
components: list[ObservationComponentData] | None = None
|
|
73
|
+
value_codings: list[CodingData] | None = None
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def values(self) -> dict[str, Any]:
|
|
77
|
+
"""Return the values of the observation as a dictionary."""
|
|
78
|
+
values = super().values
|
|
79
|
+
|
|
80
|
+
# Handle nested object serialization
|
|
81
|
+
if self.is_dirty("codings"):
|
|
82
|
+
values["codings"] = (
|
|
83
|
+
[c.to_dict() for c in self.codings] if self.codings is not None else None
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if self.is_dirty("components"):
|
|
87
|
+
values["components"] = (
|
|
88
|
+
[comp.to_dict() for comp in self.components]
|
|
89
|
+
if self.components is not None
|
|
90
|
+
else None
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if self.is_dirty("value_codings"):
|
|
94
|
+
values["value_codings"] = (
|
|
95
|
+
[vc.to_dict() for vc in self.value_codings]
|
|
96
|
+
if self.value_codings is not None
|
|
97
|
+
else None
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return values
|
|
101
|
+
|
|
102
|
+
def _get_error_details(self, method: Any) -> list[InitErrorDetails]:
|
|
103
|
+
"""Validate the observation data."""
|
|
104
|
+
errors = super()._get_error_details(method)
|
|
105
|
+
|
|
106
|
+
# Validate create-specific requirements
|
|
107
|
+
if method == "create":
|
|
108
|
+
if self.observation_id:
|
|
109
|
+
errors.append(
|
|
110
|
+
self._create_error_detail(
|
|
111
|
+
"value",
|
|
112
|
+
"Observation ID should not be set when creating a new observation.",
|
|
113
|
+
self.observation_id,
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
if not self.patient_id:
|
|
117
|
+
errors.append(
|
|
118
|
+
self._create_error_detail(
|
|
119
|
+
"value",
|
|
120
|
+
"Patient ID is required when creating a new observation.",
|
|
121
|
+
self.patient_id,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
if not self.name:
|
|
125
|
+
errors.append(
|
|
126
|
+
self._create_error_detail(
|
|
127
|
+
"value",
|
|
128
|
+
"Name is required when creating a new observation.",
|
|
129
|
+
self.name,
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
if not self.effective_datetime:
|
|
133
|
+
errors.append(
|
|
134
|
+
self._create_error_detail(
|
|
135
|
+
"value",
|
|
136
|
+
"Effective datetime is required when creating a new observation.",
|
|
137
|
+
self.effective_datetime,
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Validate update-specific requirements
|
|
142
|
+
if method == "update":
|
|
143
|
+
if not self.observation_id:
|
|
144
|
+
errors.append(
|
|
145
|
+
self._create_error_detail(
|
|
146
|
+
"value",
|
|
147
|
+
"Observation ID must be set when updating an existing observation.",
|
|
148
|
+
self.observation_id,
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
elif not ObservationModel.objects.filter(id=self.observation_id).exists():
|
|
152
|
+
errors.append(
|
|
153
|
+
self._create_error_detail(
|
|
154
|
+
"value",
|
|
155
|
+
f"Observation with ID {self.observation_id} does not exist.",
|
|
156
|
+
self.observation_id,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Validate foreign key references
|
|
161
|
+
if (
|
|
162
|
+
self.is_member_of_id
|
|
163
|
+
and not ObservationModel.objects.filter(id=self.is_member_of_id).exists()
|
|
164
|
+
):
|
|
165
|
+
errors.append(
|
|
166
|
+
self._create_error_detail(
|
|
167
|
+
"value",
|
|
168
|
+
f"Parent observation with ID {self.is_member_of_id} does not exist.",
|
|
169
|
+
self.is_member_of_id,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return errors
|
|
174
|
+
|
|
175
|
+
def create(self) -> Effect:
|
|
176
|
+
"""Create a new Observation."""
|
|
177
|
+
self._validate_before_effect("create")
|
|
178
|
+
|
|
179
|
+
return Effect(
|
|
180
|
+
type=f"CREATE_{self.Meta.effect_type}",
|
|
181
|
+
payload=json.dumps(
|
|
182
|
+
{
|
|
183
|
+
"data": self.values,
|
|
184
|
+
}
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def update(self) -> Effect:
|
|
189
|
+
"""Update an existing Observation."""
|
|
190
|
+
self._validate_before_effect("update")
|
|
191
|
+
|
|
192
|
+
return Effect(
|
|
193
|
+
type=f"UPDATE_{self.Meta.effect_type}",
|
|
194
|
+
payload=json.dumps(
|
|
195
|
+
{
|
|
196
|
+
"data": self.values,
|
|
197
|
+
}
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
__exports__ = (
|
|
203
|
+
"Observation",
|
|
204
|
+
"CodingData",
|
|
205
|
+
"ObservationComponentData",
|
|
206
|
+
)
|
|
@@ -3,6 +3,7 @@ from canvas_sdk.effects.patient.base import (
|
|
|
3
3
|
PatientAddress,
|
|
4
4
|
PatientContactPoint,
|
|
5
5
|
PatientExternalIdentifier,
|
|
6
|
+
PatientMetadata,
|
|
6
7
|
PatientPreferredPharmacy,
|
|
7
8
|
)
|
|
8
9
|
from canvas_sdk.effects.patient.create_patient_external_identifier import (
|
|
@@ -18,6 +19,7 @@ __all__ = __exports__ = (
|
|
|
18
19
|
"PatientContactPoint",
|
|
19
20
|
"PatientExternalIdentifier",
|
|
20
21
|
"PatientPreferredPharmacy",
|
|
22
|
+
"PatientMetadata",
|
|
21
23
|
"CreatePatientExternalIdentifier",
|
|
22
24
|
"CreatePatientPreferredPharmacies",
|
|
23
25
|
)
|
|
@@ -7,6 +7,7 @@ from pydantic_core import InitErrorDetails
|
|
|
7
7
|
|
|
8
8
|
from canvas_generated.messages.effects_pb2 import Effect
|
|
9
9
|
from canvas_sdk.base import TrackableFieldsModel
|
|
10
|
+
from canvas_sdk.effects.metadata import Metadata as PatientMetadata
|
|
10
11
|
from canvas_sdk.v1.data import Patient as PatientModel
|
|
11
12
|
from canvas_sdk.v1.data import PracticeLocation, Staff
|
|
12
13
|
from canvas_sdk.v1.data.common import (
|
|
@@ -127,6 +128,7 @@ class Patient(TrackableFieldsModel):
|
|
|
127
128
|
external_identifiers: list[PatientExternalIdentifier] | None = None
|
|
128
129
|
preferred_pharmacies: list[PatientPreferredPharmacy] | None = None
|
|
129
130
|
addresses: list[PatientAddress] | None = None
|
|
131
|
+
metadata: list[PatientMetadata] | None = None
|
|
130
132
|
|
|
131
133
|
@property
|
|
132
134
|
def values(self) -> dict[str, Any]:
|
|
@@ -159,6 +161,11 @@ class Patient(TrackableFieldsModel):
|
|
|
159
161
|
else None
|
|
160
162
|
)
|
|
161
163
|
|
|
164
|
+
if self.is_dirty("metadata"):
|
|
165
|
+
values["metadata"] = (
|
|
166
|
+
[md.to_dict() for md in self.metadata] if self.metadata is not None else None
|
|
167
|
+
)
|
|
168
|
+
|
|
162
169
|
return values
|
|
163
170
|
|
|
164
171
|
def _get_error_details(self, method: Any) -> list[InitErrorDetails]:
|
|
@@ -271,5 +278,6 @@ __exports__ = (
|
|
|
271
278
|
"PatientAddress",
|
|
272
279
|
"PatientContactPoint",
|
|
273
280
|
"PatientExternalIdentifier",
|
|
281
|
+
"PatientMetadata",
|
|
274
282
|
"PatientPreferredPharmacy",
|
|
275
283
|
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from canvas_sdk.effects.payment.base import ClaimAllocation, LineItemTransaction, PaymentMethod
|
|
2
|
+
from canvas_sdk.effects.payment.post_claim_payment import (
|
|
3
|
+
PostClaimPayment,
|
|
4
|
+
)
|
|
5
|
+
|
|
6
|
+
__all__ = __exports__ = (
|
|
7
|
+
"PostClaimPayment",
|
|
8
|
+
"ClaimAllocation",
|
|
9
|
+
"LineItemTransaction",
|
|
10
|
+
"PaymentMethod",
|
|
11
|
+
)
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from django.db.models import QuerySet
|
|
8
|
+
from pydantic import Field
|
|
9
|
+
from pydantic.dataclasses import dataclass
|
|
10
|
+
from pydantic_core import InitErrorDetails
|
|
11
|
+
|
|
12
|
+
from canvas_sdk.effects.base import _BaseEffect
|
|
13
|
+
from canvas_sdk.v1.data import Claim, ClaimCoverage, ClaimLineItem, ClaimQueue
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PaymentMethod(Enum):
|
|
17
|
+
"""PaymentMethods."""
|
|
18
|
+
|
|
19
|
+
CASH = "cash"
|
|
20
|
+
CHECK = "check"
|
|
21
|
+
CARD = "card"
|
|
22
|
+
OTHER = "other"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PostPaymentBase(_BaseEffect):
|
|
26
|
+
"""
|
|
27
|
+
An BaseEffect for posting payment(s) to claim(s).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
check_date: date | None = None
|
|
31
|
+
check_number: str | None = None
|
|
32
|
+
deposit_date: date | None = None
|
|
33
|
+
method: PaymentMethod
|
|
34
|
+
payment_description: str | None = None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def payment_collection_values(self) -> dict[str, Any]:
|
|
38
|
+
"""The values for the payment_collection."""
|
|
39
|
+
return {
|
|
40
|
+
"check_date": self.check_date.isoformat() if self.check_date else None,
|
|
41
|
+
"check_number": self.check_number,
|
|
42
|
+
"deposit_date": self.deposit_date.isoformat() if self.deposit_date else None,
|
|
43
|
+
"method": self.method.value,
|
|
44
|
+
"payment_description": self.payment_description,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
def validate_payment_method_fields(self) -> list[InitErrorDetails]:
|
|
48
|
+
"""Checks that check_number and check_date are provided when method is 'check'."""
|
|
49
|
+
if self.method != PaymentMethod.CHECK:
|
|
50
|
+
return []
|
|
51
|
+
errors = []
|
|
52
|
+
if not self.check_number:
|
|
53
|
+
errors.append(
|
|
54
|
+
self._create_error_detail(
|
|
55
|
+
"value", "Check number is required for payment method CHECK", self.check_number
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
if not self.check_date:
|
|
59
|
+
errors.append(
|
|
60
|
+
self._create_error_detail(
|
|
61
|
+
"value", "Check date is required for payment method CHECK", self.check_date
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
return errors
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class LineItemTransaction:
|
|
69
|
+
"""Data for creating a line item transaction on a ClaimPayment."""
|
|
70
|
+
|
|
71
|
+
claim_line_item_id: str | UUID
|
|
72
|
+
charged: Decimal | None = Field(decimal_places=2, default=None)
|
|
73
|
+
allowed: Decimal | None = Field(decimal_places=2, default=None)
|
|
74
|
+
payment: Decimal | None = Field(decimal_places=2, default=None)
|
|
75
|
+
adjustment: Decimal | None = Field(decimal_places=2, default=None)
|
|
76
|
+
adjustment_code: str | None = None
|
|
77
|
+
transfer_remaining_balance_to: str | UUID | Literal["patient"] | None = None
|
|
78
|
+
write_off: bool = False
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> dict[str, Any]:
|
|
81
|
+
"""Convert dataclass to dictionary."""
|
|
82
|
+
return {
|
|
83
|
+
"charged": str(self.charged) if self.charged is not None else None,
|
|
84
|
+
"adjustment": str(self.adjustment) if self.adjustment is not None else None,
|
|
85
|
+
"adjustment_code": self.adjustment_code,
|
|
86
|
+
"allowed": str(self.allowed) if self.allowed is not None else None,
|
|
87
|
+
"claim_line_item_id": str(self.claim_line_item_id),
|
|
88
|
+
"payment": str(self.payment) if self.payment is not None else None,
|
|
89
|
+
"transfer_to": str(self.transfer_remaining_balance_to)
|
|
90
|
+
if self.transfer_remaining_balance_to
|
|
91
|
+
else None,
|
|
92
|
+
"write_off": self.write_off,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def is_first_transaction_for_line_item(
|
|
96
|
+
self, line_item_transactions: list["LineItemTransaction"], index: int
|
|
97
|
+
) -> bool:
|
|
98
|
+
"""Returns True if this transaction is the first transaction for the claim line item, which can have many."""
|
|
99
|
+
for i, lit in enumerate(line_item_transactions):
|
|
100
|
+
if lit.claim_line_item_id == self.claim_line_item_id:
|
|
101
|
+
return i == index
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
def is_allowed_valid(self, is_patient_pmt: bool) -> list[tuple[str, str, Any]]:
|
|
105
|
+
"""Checks if allowed amount present on a patient payment."""
|
|
106
|
+
if is_patient_pmt and self.allowed:
|
|
107
|
+
return [self._format_error("Allowed amount should be $0 or None for patient postings")]
|
|
108
|
+
return []
|
|
109
|
+
|
|
110
|
+
def is_payment_required(
|
|
111
|
+
self, is_first_transaction_for_line_item: bool
|
|
112
|
+
) -> list[tuple[str, str, Any]]:
|
|
113
|
+
"""Checks that the first transaction for a claim line item is either a payment or adjustment."""
|
|
114
|
+
if (
|
|
115
|
+
self.payment is None
|
|
116
|
+
and is_first_transaction_for_line_item
|
|
117
|
+
and self.adjustment is None
|
|
118
|
+
and self.allowed is None
|
|
119
|
+
):
|
|
120
|
+
return [
|
|
121
|
+
self._format_error(
|
|
122
|
+
"Payment or adjustment is required for a claim line item's first transaction"
|
|
123
|
+
)
|
|
124
|
+
]
|
|
125
|
+
return []
|
|
126
|
+
|
|
127
|
+
def is_adjustment_required(
|
|
128
|
+
self, is_first_line_item_transaction: bool
|
|
129
|
+
) -> list[tuple[str, str, Any]]:
|
|
130
|
+
"""Checks that sequential transactions for a claim line item have an adjustment, and also checks if the adjustments have the correct corresponding fields."""
|
|
131
|
+
errors = []
|
|
132
|
+
|
|
133
|
+
if not is_first_line_item_transaction and not self.adjustment:
|
|
134
|
+
errors.append(
|
|
135
|
+
self._format_error(
|
|
136
|
+
"Specify an adjustment amount for added adjustments or remove the added adjustment line for this claim line item"
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
if not self.adjustment and self.adjustment_code:
|
|
140
|
+
errors.append(
|
|
141
|
+
self._format_error("Enter an adjustment amount for the specified adjustment type")
|
|
142
|
+
)
|
|
143
|
+
if not self.adjustment and self.transfer_remaining_balance_to:
|
|
144
|
+
errors.append(self._format_error("Enter an adjustment amount to transfer"))
|
|
145
|
+
if not self.adjustment and self.write_off:
|
|
146
|
+
errors.append(self._format_error("Enter an adjustment amount to write off"))
|
|
147
|
+
|
|
148
|
+
return errors
|
|
149
|
+
|
|
150
|
+
def is_adjustment_type_required(self) -> list[tuple[str, str, Any]]:
|
|
151
|
+
"""Checks if there is an adjustment without an adjustment code."""
|
|
152
|
+
if self.adjustment and not self.adjustment_code:
|
|
153
|
+
return [self._format_error("Specify an adjustment code for the adjustment amount")]
|
|
154
|
+
return []
|
|
155
|
+
|
|
156
|
+
def is_transfer_to_required(self) -> list[tuple[str, str, Any]]:
|
|
157
|
+
"""Checks if a transfer_to is required."""
|
|
158
|
+
if not self.adjustment or not self.adjustment_code:
|
|
159
|
+
return []
|
|
160
|
+
adjustment_type = self.adjustment_code.split("-")
|
|
161
|
+
# When "Transfer" adjustment group/code is selected a transfer destination is required.
|
|
162
|
+
# This is the only group/code with this requirement.
|
|
163
|
+
if (
|
|
164
|
+
len(adjustment_type) < 2
|
|
165
|
+
and not self.transfer_remaining_balance_to
|
|
166
|
+
and adjustment_type[0] == "Transfer"
|
|
167
|
+
):
|
|
168
|
+
return [self._format_error("Specify a payer to transfer the adjusted amount")]
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
def is_adjustment_allowed(self, is_self_copay_line_item: bool) -> list[tuple[str, str, Any]]:
|
|
172
|
+
"""Checks if the adjustment is accurately formed."""
|
|
173
|
+
errors = []
|
|
174
|
+
|
|
175
|
+
if is_self_copay_line_item and any(
|
|
176
|
+
[
|
|
177
|
+
self.adjustment,
|
|
178
|
+
self.adjustment_code,
|
|
179
|
+
self.write_off,
|
|
180
|
+
self.transfer_remaining_balance_to,
|
|
181
|
+
]
|
|
182
|
+
):
|
|
183
|
+
errors.append(
|
|
184
|
+
self._format_error("Adjustments and transfers not allowed for COPAY charges")
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if self.adjustment and self.write_off and self.transfer_remaining_balance_to:
|
|
188
|
+
errors.append(
|
|
189
|
+
self._format_error(
|
|
190
|
+
"Adjustments cannot write off and transfer at the same time, please set write off=False or transfer_remaining_balance_to=None to create the posting"
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
return errors
|
|
194
|
+
|
|
195
|
+
def is_payer_valid(
|
|
196
|
+
self, is_self_copay_line_item: bool, is_patient_pmt: bool
|
|
197
|
+
) -> list[tuple[str, str, Any]]:
|
|
198
|
+
"""Checks that payments made on a COPAY line item only come from a patient."""
|
|
199
|
+
if is_self_copay_line_item and not is_patient_pmt:
|
|
200
|
+
return [self._format_error("COPAY payments may only be posted by patients")]
|
|
201
|
+
return []
|
|
202
|
+
|
|
203
|
+
def is_transfer_to_valid(
|
|
204
|
+
self,
|
|
205
|
+
claim_coverage_id: str | UUID | Literal["patient"],
|
|
206
|
+
claim_coverages: QuerySet[ClaimCoverage],
|
|
207
|
+
) -> list[tuple[str, str, Any]]:
|
|
208
|
+
"""Checks that transfers are not made to the same payer as the transaction payer and that the transfer_to field is a valid payer for the claim."""
|
|
209
|
+
if not self.transfer_remaining_balance_to:
|
|
210
|
+
return []
|
|
211
|
+
|
|
212
|
+
if self.transfer_remaining_balance_to and str(self.transfer_remaining_balance_to) == str(
|
|
213
|
+
claim_coverage_id
|
|
214
|
+
):
|
|
215
|
+
return [self._format_error("Can't create transfers to same payer")]
|
|
216
|
+
|
|
217
|
+
if (
|
|
218
|
+
self.transfer_remaining_balance_to != "patient"
|
|
219
|
+
and not claim_coverages.filter(id=self.transfer_remaining_balance_to).exists()
|
|
220
|
+
):
|
|
221
|
+
return [
|
|
222
|
+
self._format_error(
|
|
223
|
+
"Balance can only be transferred to patient or an active coverage for the claim"
|
|
224
|
+
)
|
|
225
|
+
]
|
|
226
|
+
return []
|
|
227
|
+
|
|
228
|
+
def _format_error(self, error_message: str) -> tuple[str, str, Any]:
|
|
229
|
+
return ("value", error_message, self.to_dict())
|
|
230
|
+
|
|
231
|
+
def validate(
|
|
232
|
+
self,
|
|
233
|
+
line_item_transactions: list["LineItemTransaction"],
|
|
234
|
+
index: int,
|
|
235
|
+
claim_line_items: QuerySet[ClaimLineItem],
|
|
236
|
+
claim_coverage_id: str | UUID | Literal["patient"],
|
|
237
|
+
active_claim_coverages: QuerySet[ClaimCoverage],
|
|
238
|
+
) -> list[tuple[str, str, Any]]:
|
|
239
|
+
"""Returns error details for a line item transaction, using the context of other transactions for the claim."""
|
|
240
|
+
if not (line_item := claim_line_items.filter(id=self.claim_line_item_id).first()):
|
|
241
|
+
return [
|
|
242
|
+
self._format_error(
|
|
243
|
+
"The provided claim_line_item_id does not correspond to an existing ClaimLineItem"
|
|
244
|
+
)
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
is_patient_pmt = claim_coverage_id == "patient"
|
|
248
|
+
if errors := self.is_allowed_valid(is_patient_pmt):
|
|
249
|
+
return errors
|
|
250
|
+
|
|
251
|
+
is_first_transaction_for_line_item = self.is_first_transaction_for_line_item(
|
|
252
|
+
line_item_transactions, index
|
|
253
|
+
)
|
|
254
|
+
if errors := self.is_payment_required(is_first_transaction_for_line_item):
|
|
255
|
+
return errors
|
|
256
|
+
if errors := self.is_adjustment_required(is_first_transaction_for_line_item):
|
|
257
|
+
return errors
|
|
258
|
+
|
|
259
|
+
if errors := self.is_adjustment_type_required():
|
|
260
|
+
return errors
|
|
261
|
+
if errors := self.is_transfer_to_required():
|
|
262
|
+
return errors
|
|
263
|
+
|
|
264
|
+
is_self_copay_line_item = line_item.proc_code == "COPAY"
|
|
265
|
+
if errors := self.is_adjustment_allowed(is_self_copay_line_item):
|
|
266
|
+
return errors
|
|
267
|
+
if errors := self.is_payer_valid(is_self_copay_line_item, is_patient_pmt):
|
|
268
|
+
return errors
|
|
269
|
+
|
|
270
|
+
if errors := self.is_transfer_to_valid(claim_coverage_id, active_claim_coverages):
|
|
271
|
+
return errors
|
|
272
|
+
|
|
273
|
+
return []
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@dataclass
|
|
277
|
+
class ClaimAllocation:
|
|
278
|
+
"""Claim payment details."""
|
|
279
|
+
|
|
280
|
+
claim_id: str | UUID
|
|
281
|
+
claim_coverage_id: str | UUID | Literal["patient"]
|
|
282
|
+
line_item_transactions: list[LineItemTransaction]
|
|
283
|
+
move_to_queue_name: str | None = None
|
|
284
|
+
description: str | None = None
|
|
285
|
+
|
|
286
|
+
def to_dict(self) -> dict[str, Any]:
|
|
287
|
+
"""Convert dataclass to dictionary."""
|
|
288
|
+
return {
|
|
289
|
+
"claim_id": str(self.claim_id),
|
|
290
|
+
"claim_coverage_id": str(self.claim_coverage_id),
|
|
291
|
+
"line_item_transactions": [lit.to_dict() for lit in self.line_item_transactions],
|
|
292
|
+
"move_to_queue_name": self.move_to_queue_name,
|
|
293
|
+
"description": self.description,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
def validate_claim_coverage(
|
|
297
|
+
self, active_claim_coverages: QuerySet[ClaimCoverage], payer_id: str | None = None
|
|
298
|
+
) -> str | None:
|
|
299
|
+
"""Checks that coverage is active, and if from ClaimsRemit that payer_id provided matches the coverage payer_id."""
|
|
300
|
+
if not payer_id and self.claim_coverage_id == "patient":
|
|
301
|
+
return None
|
|
302
|
+
filters = {"id": self.claim_coverage_id} | ({} if not payer_id else {"payer_id": payer_id})
|
|
303
|
+
if active_claim_coverages.filter(**filters):
|
|
304
|
+
return None
|
|
305
|
+
payer_id_message = f" with payer_id {payer_id}" if payer_id else ""
|
|
306
|
+
return f"The provided claim_coverage_id does not correspond to an active coverage for the claim{payer_id_message}"
|
|
307
|
+
|
|
308
|
+
def validate_move_to_queue_name(self) -> list[tuple[str, str, Any]]:
|
|
309
|
+
"""Checks that the queue to move to is a valid name."""
|
|
310
|
+
if (
|
|
311
|
+
not self.move_to_queue_name
|
|
312
|
+
or ClaimQueue.objects.filter(name=self.move_to_queue_name).exists()
|
|
313
|
+
):
|
|
314
|
+
return []
|
|
315
|
+
return [
|
|
316
|
+
(
|
|
317
|
+
"value",
|
|
318
|
+
"The provided move_to_queue_name does not correspond to an existing ClaimQueue",
|
|
319
|
+
self.move_to_queue_name,
|
|
320
|
+
)
|
|
321
|
+
]
|
|
322
|
+
|
|
323
|
+
def validate_claim_id(self) -> Claim | None:
|
|
324
|
+
"""Checks that the claim exists."""
|
|
325
|
+
return Claim.objects.filter(id=self.claim_id).first()
|
|
326
|
+
|
|
327
|
+
def validate(self, payer_id: str | None = None) -> list[tuple[str, str, Any]]:
|
|
328
|
+
"""Returns error details for a claim allocation."""
|
|
329
|
+
if not (claim := self.validate_claim_id()):
|
|
330
|
+
claim_error = "The provided claim_id does not correspond with an existing Claim"
|
|
331
|
+
return [("value", claim_error, str(self.claim_id))]
|
|
332
|
+
active_claim_coverages = claim.coverages.active()
|
|
333
|
+
if coverage_error := self.validate_claim_coverage(active_claim_coverages, payer_id):
|
|
334
|
+
return [("value", coverage_error, {"claim_coverage_id": str(self.claim_coverage_id)})]
|
|
335
|
+
|
|
336
|
+
errors = []
|
|
337
|
+
errors.extend(self.validate_move_to_queue_name())
|
|
338
|
+
for index, line_item_transaction in enumerate(self.line_item_transactions):
|
|
339
|
+
errors.extend(
|
|
340
|
+
line_item_transaction.validate(
|
|
341
|
+
self.line_item_transactions,
|
|
342
|
+
index,
|
|
343
|
+
claim.line_items.active(),
|
|
344
|
+
self.claim_coverage_id,
|
|
345
|
+
active_claim_coverages,
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
return errors
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
__exports__ = (
|
|
352
|
+
"PaymentMethod",
|
|
353
|
+
"LineItemTransaction",
|
|
354
|
+
"ClaimAllocation",
|
|
355
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from decimal import Decimal
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from pydantic_core import InitErrorDetails
|
|
5
|
+
|
|
6
|
+
from canvas_sdk.effects.base import EffectType
|
|
7
|
+
from canvas_sdk.effects.payment.base import ClaimAllocation, PostPaymentBase
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PostClaimPayment(PostPaymentBase):
|
|
11
|
+
"""
|
|
12
|
+
An Effect that posts a coverage or patient payment to a claim.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
class Meta:
|
|
16
|
+
effect_type = EffectType.POST_CLAIM_PAYMENT
|
|
17
|
+
|
|
18
|
+
claim: ClaimAllocation
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def total_collected(self) -> str:
|
|
22
|
+
"""The total amount collected for this payment, calculated as sum of payments from line item transactions."""
|
|
23
|
+
total = sum((item.payment or Decimal(0)) for item in self.claim.line_item_transactions)
|
|
24
|
+
return str(total)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def payment_collection_values(self) -> dict[str, Any]:
|
|
28
|
+
"""The values for the payment collection part of the payload."""
|
|
29
|
+
base_values = super().payment_collection_values
|
|
30
|
+
return base_values | {"total_collected": self.total_collected}
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def values(self) -> dict[str, Any]:
|
|
34
|
+
"""The values for the payload."""
|
|
35
|
+
return {
|
|
36
|
+
"payment_collection": self.payment_collection_values,
|
|
37
|
+
"claims_allocation": [self.claim.to_dict()],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def _get_error_details(self, method: Any) -> list[InitErrorDetails]:
|
|
41
|
+
errors = super()._get_error_details(method)
|
|
42
|
+
|
|
43
|
+
errors.extend([self._create_error_detail(*e) for e in self.claim.validate()])
|
|
44
|
+
errors.extend(self.validate_payment_method_fields())
|
|
45
|
+
|
|
46
|
+
return errors
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__exports__ = ("PostClaimPayment",)
|