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.
Files changed (185) hide show
  1. {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/METADATA +4 -1
  2. {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/RECORD +184 -98
  3. {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/WHEEL +1 -1
  4. canvas_cli/apps/emit/event_fixtures/UNKNOWN.ndjson +1 -0
  5. canvas_cli/apps/logs/logs.py +386 -22
  6. canvas_cli/main.py +3 -1
  7. canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/tests/test_models.py +46 -4
  8. canvas_cli/utils/context/context.py +13 -13
  9. canvas_cli/utils/validators/manifest_schema.py +26 -1
  10. canvas_generated/messages/effects_pb2.py +5 -5
  11. canvas_generated/messages/effects_pb2.pyi +108 -2
  12. canvas_generated/messages/events_pb2.py +6 -6
  13. canvas_generated/messages/events_pb2.pyi +282 -2
  14. canvas_sdk/clients/__init__.py +1 -0
  15. canvas_sdk/clients/llms/__init__.py +17 -0
  16. canvas_sdk/clients/llms/libraries/__init__.py +11 -0
  17. canvas_sdk/clients/llms/libraries/llm_anthropic.py +87 -0
  18. canvas_sdk/clients/llms/libraries/llm_api.py +143 -0
  19. canvas_sdk/clients/llms/libraries/llm_google.py +92 -0
  20. canvas_sdk/clients/llms/libraries/llm_openai.py +98 -0
  21. canvas_sdk/clients/llms/structures/__init__.py +9 -0
  22. canvas_sdk/clients/llms/structures/llm_response.py +33 -0
  23. canvas_sdk/clients/llms/structures/llm_tokens.py +53 -0
  24. canvas_sdk/clients/llms/structures/llm_turn.py +47 -0
  25. canvas_sdk/clients/llms/structures/settings/__init__.py +13 -0
  26. canvas_sdk/clients/llms/structures/settings/llm_settings.py +27 -0
  27. canvas_sdk/clients/llms/structures/settings/llm_settings_anthropic.py +43 -0
  28. canvas_sdk/clients/llms/structures/settings/llm_settings_gemini.py +40 -0
  29. canvas_sdk/clients/llms/structures/settings/llm_settings_gpt4.py +40 -0
  30. canvas_sdk/clients/llms/structures/settings/llm_settings_gpt5.py +48 -0
  31. canvas_sdk/clients/third_party.py +3 -0
  32. canvas_sdk/commands/__init__.py +12 -0
  33. canvas_sdk/commands/base.py +33 -2
  34. canvas_sdk/commands/commands/adjust_prescription.py +4 -0
  35. canvas_sdk/commands/commands/custom_command.py +86 -0
  36. canvas_sdk/commands/commands/family_history.py +17 -1
  37. canvas_sdk/commands/commands/immunization_statement.py +42 -2
  38. canvas_sdk/commands/commands/medication_statement.py +16 -1
  39. canvas_sdk/commands/commands/past_surgical_history.py +16 -1
  40. canvas_sdk/commands/commands/perform.py +18 -1
  41. canvas_sdk/commands/commands/prescribe.py +8 -9
  42. canvas_sdk/commands/commands/refill.py +5 -5
  43. canvas_sdk/commands/commands/resolve_condition.py +5 -5
  44. canvas_sdk/commands/commands/review/__init__.py +3 -0
  45. canvas_sdk/commands/commands/review/base.py +72 -0
  46. canvas_sdk/commands/commands/review/imaging.py +13 -0
  47. canvas_sdk/commands/commands/review/lab.py +13 -0
  48. canvas_sdk/commands/commands/review/referral.py +13 -0
  49. canvas_sdk/commands/commands/review/uncategorized_document.py +13 -0
  50. canvas_sdk/commands/validation.py +43 -0
  51. canvas_sdk/effects/batch_originate.py +22 -0
  52. canvas_sdk/effects/calendar/__init__.py +13 -3
  53. canvas_sdk/effects/calendar/{create_calendar.py → calendar.py} +19 -5
  54. canvas_sdk/effects/calendar/event.py +172 -0
  55. canvas_sdk/effects/claim_label.py +93 -0
  56. canvas_sdk/effects/claim_line_item.py +47 -0
  57. canvas_sdk/effects/claim_queue.py +49 -0
  58. canvas_sdk/effects/fax/__init__.py +3 -0
  59. canvas_sdk/effects/fax/base.py +77 -0
  60. canvas_sdk/effects/fax/note.py +42 -0
  61. canvas_sdk/effects/metadata.py +15 -1
  62. canvas_sdk/effects/note/__init__.py +8 -1
  63. canvas_sdk/effects/note/appointment.py +135 -7
  64. canvas_sdk/effects/note/base.py +17 -0
  65. canvas_sdk/effects/note/message.py +22 -14
  66. canvas_sdk/effects/note/note.py +150 -1
  67. canvas_sdk/effects/observation/__init__.py +11 -0
  68. canvas_sdk/effects/observation/base.py +206 -0
  69. canvas_sdk/effects/patient/__init__.py +2 -0
  70. canvas_sdk/effects/patient/base.py +8 -0
  71. canvas_sdk/effects/payment/__init__.py +11 -0
  72. canvas_sdk/effects/payment/base.py +355 -0
  73. canvas_sdk/effects/payment/post_claim_payment.py +49 -0
  74. canvas_sdk/effects/send_contact_verification.py +42 -0
  75. canvas_sdk/effects/task/__init__.py +2 -1
  76. canvas_sdk/effects/task/task.py +30 -0
  77. canvas_sdk/effects/validation/__init__.py +3 -0
  78. canvas_sdk/effects/validation/base.py +92 -0
  79. canvas_sdk/events/base.py +15 -0
  80. canvas_sdk/handlers/application.py +7 -7
  81. canvas_sdk/handlers/simple_api/api.py +1 -4
  82. canvas_sdk/handlers/simple_api/websocket.py +1 -4
  83. canvas_sdk/handlers/utils.py +14 -0
  84. canvas_sdk/questionnaires/utils.py +1 -0
  85. canvas_sdk/templates/utils.py +17 -4
  86. canvas_sdk/test_utils/factories/FACTORY_GUIDE.md +362 -0
  87. canvas_sdk/test_utils/factories/__init__.py +115 -0
  88. canvas_sdk/test_utils/factories/calendar.py +24 -0
  89. canvas_sdk/test_utils/factories/claim.py +81 -0
  90. canvas_sdk/test_utils/factories/claim_diagnosis_code.py +16 -0
  91. canvas_sdk/test_utils/factories/coverage.py +17 -0
  92. canvas_sdk/test_utils/factories/imaging.py +74 -0
  93. canvas_sdk/test_utils/factories/lab.py +192 -0
  94. canvas_sdk/test_utils/factories/medication_history.py +75 -0
  95. canvas_sdk/test_utils/factories/note.py +52 -0
  96. canvas_sdk/test_utils/factories/organization.py +50 -0
  97. canvas_sdk/test_utils/factories/practicelocation.py +88 -0
  98. canvas_sdk/test_utils/factories/referral.py +81 -0
  99. canvas_sdk/test_utils/factories/staff.py +111 -0
  100. canvas_sdk/test_utils/factories/task.py +66 -0
  101. canvas_sdk/test_utils/factories/uncategorized_clinical_document.py +48 -0
  102. canvas_sdk/utils/metrics.py +4 -1
  103. canvas_sdk/v1/data/__init__.py +66 -7
  104. canvas_sdk/v1/data/allergy_intolerance.py +5 -11
  105. canvas_sdk/v1/data/appointment.py +18 -4
  106. canvas_sdk/v1/data/assessment.py +2 -12
  107. canvas_sdk/v1/data/banner_alert.py +2 -4
  108. canvas_sdk/v1/data/base.py +53 -14
  109. canvas_sdk/v1/data/billing.py +8 -11
  110. canvas_sdk/v1/data/calendar.py +64 -0
  111. canvas_sdk/v1/data/care_team.py +4 -10
  112. canvas_sdk/v1/data/claim.py +172 -66
  113. canvas_sdk/v1/data/claim_diagnosis_code.py +19 -0
  114. canvas_sdk/v1/data/claim_line_item.py +2 -5
  115. canvas_sdk/v1/data/coding.py +19 -0
  116. canvas_sdk/v1/data/command.py +2 -4
  117. canvas_sdk/v1/data/common.py +10 -0
  118. canvas_sdk/v1/data/compound_medication.py +3 -4
  119. canvas_sdk/v1/data/condition.py +4 -9
  120. canvas_sdk/v1/data/coverage.py +66 -26
  121. canvas_sdk/v1/data/detected_issue.py +20 -20
  122. canvas_sdk/v1/data/device.py +2 -14
  123. canvas_sdk/v1/data/discount.py +2 -5
  124. canvas_sdk/v1/data/encounter.py +44 -0
  125. canvas_sdk/v1/data/facility.py +1 -0
  126. canvas_sdk/v1/data/goal.py +2 -14
  127. canvas_sdk/v1/data/imaging.py +4 -30
  128. canvas_sdk/v1/data/immunization.py +7 -15
  129. canvas_sdk/v1/data/lab.py +12 -65
  130. canvas_sdk/v1/data/line_item_transaction.py +2 -5
  131. canvas_sdk/v1/data/medication.py +3 -8
  132. canvas_sdk/v1/data/medication_history.py +142 -0
  133. canvas_sdk/v1/data/medication_statement.py +41 -0
  134. canvas_sdk/v1/data/message.py +4 -8
  135. canvas_sdk/v1/data/note.py +37 -38
  136. canvas_sdk/v1/data/observation.py +9 -36
  137. canvas_sdk/v1/data/organization.py +70 -9
  138. canvas_sdk/v1/data/patient.py +8 -12
  139. canvas_sdk/v1/data/patient_consent.py +4 -14
  140. canvas_sdk/v1/data/payment_collection.py +2 -5
  141. canvas_sdk/v1/data/posting.py +3 -9
  142. canvas_sdk/v1/data/practicelocation.py +66 -7
  143. canvas_sdk/v1/data/protocol_override.py +3 -4
  144. canvas_sdk/v1/data/protocol_result.py +3 -3
  145. canvas_sdk/v1/data/questionnaire.py +10 -26
  146. canvas_sdk/v1/data/reason_for_visit.py +2 -6
  147. canvas_sdk/v1/data/referral.py +41 -17
  148. canvas_sdk/v1/data/staff.py +34 -26
  149. canvas_sdk/v1/data/stop_medication_event.py +27 -0
  150. canvas_sdk/v1/data/task.py +30 -11
  151. canvas_sdk/v1/data/team.py +2 -4
  152. canvas_sdk/v1/data/uncategorized_clinical_document.py +84 -0
  153. canvas_sdk/v1/data/user.py +14 -0
  154. canvas_sdk/v1/data/utils.py +5 -0
  155. canvas_sdk/value_set/v2026/__init__.py +1 -0
  156. canvas_sdk/value_set/v2026/adverse_event.py +157 -0
  157. canvas_sdk/value_set/v2026/allergy.py +116 -0
  158. canvas_sdk/value_set/v2026/assessment.py +466 -0
  159. canvas_sdk/value_set/v2026/communication.py +496 -0
  160. canvas_sdk/value_set/v2026/condition.py +52934 -0
  161. canvas_sdk/value_set/v2026/device.py +315 -0
  162. canvas_sdk/value_set/v2026/diagnostic_study.py +5243 -0
  163. canvas_sdk/value_set/v2026/encounter.py +2714 -0
  164. canvas_sdk/value_set/v2026/immunization.py +297 -0
  165. canvas_sdk/value_set/v2026/individual_characteristic.py +339 -0
  166. canvas_sdk/value_set/v2026/intervention.py +1703 -0
  167. canvas_sdk/value_set/v2026/laboratory_test.py +1831 -0
  168. canvas_sdk/value_set/v2026/medication.py +8218 -0
  169. canvas_sdk/value_set/v2026/no_qdm_category_assigned.py +26493 -0
  170. canvas_sdk/value_set/v2026/physical_exam.py +342 -0
  171. canvas_sdk/value_set/v2026/procedure.py +27869 -0
  172. canvas_sdk/value_set/v2026/symptom.py +625 -0
  173. logger/logger.py +30 -31
  174. logger/logstash.py +282 -0
  175. logger/pubsub.py +26 -0
  176. plugin_runner/allowed-module-imports.json +940 -9
  177. plugin_runner/generate_allowed_imports.py +1 -0
  178. plugin_runner/installation.py +2 -2
  179. plugin_runner/plugin_runner.py +21 -24
  180. plugin_runner/sandbox.py +34 -0
  181. protobufs/canvas_generated/messages/effects.proto +65 -0
  182. protobufs/canvas_generated/messages/events.proto +150 -51
  183. settings.py +27 -11
  184. canvas_sdk/effects/calendar/create_event.py +0 -43
  185. {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",)