geek-cafe-saas-sdk 0.7.0__py3-none-any.whl → 0.7.2__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.

Potentially problematic release.


This version of geek-cafe-saas-sdk might be problematic. Click here for more details.

Files changed (79) hide show
  1. geek_cafe_saas_sdk/__init__.py +1 -1
  2. geek_cafe_saas_sdk/domains/files/models/directory.py +42 -6
  3. geek_cafe_saas_sdk/domains/files/models/file.py +40 -4
  4. geek_cafe_saas_sdk/domains/files/models/file_share.py +33 -0
  5. geek_cafe_saas_sdk/domains/files/models/file_version.py +24 -0
  6. geek_cafe_saas_sdk/domains/files/services/directory_service.py +54 -135
  7. geek_cafe_saas_sdk/domains/files/services/file_share_service.py +60 -136
  8. geek_cafe_saas_sdk/domains/files/services/file_system_service.py +43 -104
  9. geek_cafe_saas_sdk/domains/files/services/file_version_service.py +57 -131
  10. geek_cafe_saas_sdk/domains/messaging/services/contact_thread_service.py +55 -7
  11. geek_cafe_saas_sdk/domains/notifications/__init__.py +18 -0
  12. geek_cafe_saas_sdk/domains/notifications/handlers/__init__.py +1 -0
  13. geek_cafe_saas_sdk/domains/notifications/handlers/create_webhook/app.py +73 -0
  14. geek_cafe_saas_sdk/domains/notifications/handlers/get/app.py +40 -0
  15. geek_cafe_saas_sdk/domains/notifications/handlers/get_preferences/app.py +34 -0
  16. geek_cafe_saas_sdk/domains/notifications/handlers/list/app.py +43 -0
  17. geek_cafe_saas_sdk/domains/notifications/handlers/list_webhooks/app.py +40 -0
  18. geek_cafe_saas_sdk/domains/notifications/handlers/mark_read/app.py +40 -0
  19. geek_cafe_saas_sdk/domains/notifications/handlers/send/app.py +83 -0
  20. geek_cafe_saas_sdk/domains/notifications/handlers/update_preferences/app.py +45 -0
  21. geek_cafe_saas_sdk/domains/notifications/models/__init__.py +16 -0
  22. geek_cafe_saas_sdk/domains/notifications/models/notification.py +717 -0
  23. geek_cafe_saas_sdk/domains/notifications/models/notification_preference.py +365 -0
  24. geek_cafe_saas_sdk/domains/notifications/models/webhook_subscription.py +339 -0
  25. geek_cafe_saas_sdk/domains/notifications/services/__init__.py +10 -0
  26. geek_cafe_saas_sdk/domains/notifications/services/notification_service.py +576 -0
  27. geek_cafe_saas_sdk/domains/payments/__init__.py +16 -0
  28. geek_cafe_saas_sdk/domains/payments/handlers/README.md +334 -0
  29. geek_cafe_saas_sdk/domains/payments/handlers/__init__.py +6 -0
  30. geek_cafe_saas_sdk/domains/payments/handlers/billing_accounts/create/app.py +105 -0
  31. geek_cafe_saas_sdk/domains/payments/handlers/billing_accounts/get/app.py +60 -0
  32. geek_cafe_saas_sdk/domains/payments/handlers/billing_accounts/update/app.py +97 -0
  33. geek_cafe_saas_sdk/domains/payments/handlers/payment_intents/create/app.py +97 -0
  34. geek_cafe_saas_sdk/domains/payments/handlers/payment_intents/get/app.py +60 -0
  35. geek_cafe_saas_sdk/domains/payments/handlers/payments/get/app.py +60 -0
  36. geek_cafe_saas_sdk/domains/payments/handlers/payments/list/app.py +68 -0
  37. geek_cafe_saas_sdk/domains/payments/handlers/payments/record/app.py +118 -0
  38. geek_cafe_saas_sdk/domains/payments/handlers/refunds/create/app.py +89 -0
  39. geek_cafe_saas_sdk/domains/payments/handlers/refunds/get/app.py +60 -0
  40. geek_cafe_saas_sdk/domains/payments/models/__init__.py +17 -0
  41. geek_cafe_saas_sdk/domains/payments/models/billing_account.py +521 -0
  42. geek_cafe_saas_sdk/domains/payments/models/payment.py +639 -0
  43. geek_cafe_saas_sdk/domains/payments/models/payment_intent_ref.py +539 -0
  44. geek_cafe_saas_sdk/domains/payments/models/refund.py +404 -0
  45. geek_cafe_saas_sdk/domains/payments/services/__init__.py +11 -0
  46. geek_cafe_saas_sdk/domains/payments/services/payment_service.py +405 -0
  47. geek_cafe_saas_sdk/domains/subscriptions/__init__.py +19 -0
  48. geek_cafe_saas_sdk/domains/subscriptions/handlers/README.md +408 -0
  49. geek_cafe_saas_sdk/domains/subscriptions/handlers/__init__.py +1 -0
  50. geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/create/app.py +81 -0
  51. geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/get/app.py +48 -0
  52. geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/list/app.py +54 -0
  53. geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/update/app.py +54 -0
  54. geek_cafe_saas_sdk/domains/subscriptions/handlers/discounts/create/app.py +83 -0
  55. geek_cafe_saas_sdk/domains/subscriptions/handlers/discounts/get/app.py +47 -0
  56. geek_cafe_saas_sdk/domains/subscriptions/handlers/discounts/validate/app.py +62 -0
  57. geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/create/app.py +82 -0
  58. geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/get/app.py +48 -0
  59. geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/list/app.py +66 -0
  60. geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/update/app.py +54 -0
  61. geek_cafe_saas_sdk/domains/subscriptions/handlers/usage/aggregate/app.py +72 -0
  62. geek_cafe_saas_sdk/domains/subscriptions/handlers/usage/record/app.py +89 -0
  63. geek_cafe_saas_sdk/domains/subscriptions/models/__init__.py +13 -0
  64. geek_cafe_saas_sdk/domains/subscriptions/models/addon.py +604 -0
  65. geek_cafe_saas_sdk/domains/subscriptions/models/discount.py +492 -0
  66. geek_cafe_saas_sdk/domains/subscriptions/models/plan.py +569 -0
  67. geek_cafe_saas_sdk/domains/subscriptions/models/usage_record.py +300 -0
  68. geek_cafe_saas_sdk/domains/subscriptions/services/__init__.py +10 -0
  69. geek_cafe_saas_sdk/domains/subscriptions/services/subscription_manager_service.py +694 -0
  70. geek_cafe_saas_sdk/domains/tenancy/models/subscription.py +123 -1
  71. geek_cafe_saas_sdk/domains/tenancy/services/subscription_service.py +213 -0
  72. geek_cafe_saas_sdk/lambda_handlers/_base/base_handler.py +7 -0
  73. geek_cafe_saas_sdk/services/database_service.py +10 -6
  74. geek_cafe_saas_sdk/utilities/environment_variables.py +16 -0
  75. geek_cafe_saas_sdk/utilities/logging_utility.py +77 -0
  76. {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.2.dist-info}/METADATA +1 -1
  77. {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.2.dist-info}/RECORD +79 -20
  78. {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.2.dist-info}/WHEEL +0 -0
  79. {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,539 @@
1
+ """
2
+ PaymentIntentRef model for payment system.
3
+
4
+ Geek Cafe, LLC
5
+ MIT License. See Project Root for the license information.
6
+ """
7
+
8
+ import datetime as dt
9
+ from typing import Optional, Dict, Any
10
+ from geek_cafe_saas_sdk.models.base_model import BaseModel
11
+ from boto3_assist.dynamodb.dynamodb_index import DynamoDBIndex, DynamoDBKey
12
+
13
+
14
+ class PaymentIntentRef(BaseModel):
15
+ """
16
+ PaymentIntentRef - PSP payment intent reference and status tracking.
17
+
18
+ Represents a payment intent created with a payment service provider (PSP)
19
+ like Stripe. Tracks the intent lifecycle from creation through completion
20
+ or cancellation. Used to handle async payment flows and webhooks.
21
+
22
+ Multi-Tenancy:
23
+ - tenant_id: Organization/company initiating the payment
24
+
25
+ Access Patterns (DynamoDB Keys):
26
+ - pk: PAYMENT_INTENT#{tenant_id}#{intent_ref_id}
27
+ - sk: metadata
28
+ - gsi1_pk: tenant#{tenant_id}
29
+ - gsi1_sk: PAYMENT_INTENT#{created_utc_ts}
30
+ - gsi2_pk: PSP_INTENT#{psp_type}#{psp_intent_id}
31
+ - gsi2_sk: metadata
32
+ - gsi3_pk: BILLING_ACCOUNT#{billing_account_id}
33
+ - gsi3_sk: PAYMENT_INTENT#{created_utc_ts}
34
+ """
35
+
36
+ def __init__(self):
37
+ super().__init__()
38
+
39
+ # Identity (inherited from BaseModel: id, tenant_id)
40
+ self._billing_account_id: str | None = None # Associated billing account
41
+
42
+ # PSP Information
43
+ self._psp_type: str = "stripe" # "stripe", "paypal", "square", etc.
44
+ self._psp_intent_id: str | None = None # PSP's intent identifier
45
+ self._psp_client_secret: str | None = None # Client secret for frontend
46
+
47
+ # Amount (in cents to avoid float issues)
48
+ self._amount_cents: int = 0 # Total amount
49
+ self._currency_code: str = "USD" # ISO 4217 currency code
50
+
51
+ # Payment Method
52
+ self._payment_method_id: str | None = None # PSP payment method ID
53
+ self._payment_method_type: str | None = None # "card", "ach_debit", etc.
54
+ self._payment_method_last4: str | None = None # Last 4 digits
55
+ self._payment_method_brand: str | None = None # "visa", "mastercard", etc.
56
+
57
+ # Status Tracking
58
+ self._status: str = "created" # Status of the payment intent
59
+ self._status_history: list[Dict[str, Any]] = [] # Status change log
60
+ self._last_status_change_utc_ts: float | None = None
61
+
62
+ # Processing Details
63
+ self._setup_future_usage: str | None = None # "on_session", "off_session"
64
+ self._capture_method: str = "automatic" # "automatic", "manual"
65
+ self._confirmation_method: str = "automatic" # "automatic", "manual"
66
+
67
+ # Metadata
68
+ self._description: str | None = None # Payment description
69
+ self._statement_descriptor: str | None = None # Appears on card statement
70
+ self._receipt_email: str | None = None # Email for receipt
71
+
72
+ # Error Tracking
73
+ self._error_code: str | None = None # PSP error code
74
+ self._error_message: str | None = None # Human-readable error
75
+ self._error_type: str | None = None # "card_error", "invalid_request", etc.
76
+
77
+ # Webhooks & Events
78
+ self._last_webhook_utc_ts: float | None = None # Last webhook received
79
+ self._webhook_count: int = 0 # Number of webhooks received
80
+
81
+ # Related Records
82
+ self._payment_id: str | None = None # Settled Payment record (if succeeded)
83
+ self._invoice_id: str | None = None # Related invoice
84
+ self._subscription_id: str | None = None # Related subscription
85
+
86
+ # Cancellation
87
+ self._canceled_utc_ts: float | None = None
88
+ self._cancellation_reason: str | None = None # "duplicate", "fraudulent", etc.
89
+
90
+ # Additional PSP Data
91
+ self._psp_metadata: Dict[str, Any] | None = None # Raw PSP data
92
+
93
+ # CRITICAL: Call _setup_indexes() as LAST line in __init__
94
+ self._setup_indexes()
95
+
96
+ def _setup_indexes(self):
97
+ """Setup DynamoDB indexes for payment intent queries."""
98
+
99
+ # Primary index: Payment intent by ID
100
+ primary = DynamoDBIndex()
101
+ primary.name = "primary"
102
+ primary.partition_key.attribute_name = "pk"
103
+ primary.partition_key.value = lambda: DynamoDBKey.build_key(("payment_intent", self.id))
104
+ primary.sort_key.attribute_name = "sk"
105
+ primary.sort_key.value = lambda: "metadata"
106
+ self.indexes.add_primary(primary)
107
+
108
+ # GSI1: Payment intents by tenant
109
+ gsi = DynamoDBIndex()
110
+ gsi.name = "gsi1"
111
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
112
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(("tenant", self.tenant_id))
113
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
114
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(("payment_intent", self.created_utc_ts))
115
+ self.indexes.add_secondary(gsi)
116
+
117
+ # GSI2: Payment intent by PSP intent ID (for webhook lookups)
118
+ if self.psp_intent_id:
119
+ gsi = DynamoDBIndex()
120
+ gsi.name = "gsi2"
121
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
122
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(("psp_intent", self.psp_type), ("intent", self.psp_intent_id))
123
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
124
+ gsi.sort_key.value = lambda: "metadata"
125
+ self.indexes.add_secondary(gsi)
126
+
127
+ # Status Constants
128
+ STATUS_CREATED = "created"
129
+ STATUS_PROCESSING = "processing"
130
+ STATUS_REQUIRES_ACTION = "requires_action" # e.g., 3D Secure
131
+ STATUS_REQUIRES_CONFIRMATION = "requires_confirmation"
132
+ STATUS_REQUIRES_PAYMENT_METHOD = "requires_payment_method"
133
+ STATUS_SUCCEEDED = "succeeded"
134
+ STATUS_CANCELED = "canceled"
135
+ STATUS_FAILED = "failed"
136
+
137
+ # Properties - Identity
138
+ @property
139
+ def intent_ref_id(self) -> str | None:
140
+ """Unique intent reference ID (alias for id)."""
141
+ return self.id
142
+
143
+ @intent_ref_id.setter
144
+ def intent_ref_id(self, value: str | None):
145
+ self.id = value
146
+
147
+ @property
148
+ def billing_account_id(self) -> str | None:
149
+ """Associated billing account ID."""
150
+ return self._billing_account_id
151
+
152
+ @billing_account_id.setter
153
+ def billing_account_id(self, value: str | None):
154
+ self._billing_account_id = value
155
+
156
+ # Properties - PSP Information
157
+ @property
158
+ def psp_type(self) -> str:
159
+ """Payment service provider type."""
160
+ return self._psp_type
161
+
162
+ @psp_type.setter
163
+ def psp_type(self, value: str):
164
+ valid_types = ["stripe", "paypal", "square", "braintree"]
165
+ if value not in valid_types:
166
+ raise ValueError(f"Invalid psp_type: {value}. Must be one of {valid_types}")
167
+ self._psp_type = value
168
+
169
+ @property
170
+ def psp_intent_id(self) -> str | None:
171
+ """PSP's payment intent identifier."""
172
+ return self._psp_intent_id
173
+
174
+ @psp_intent_id.setter
175
+ def psp_intent_id(self, value: str | None):
176
+ self._psp_intent_id = value
177
+
178
+ @property
179
+ def psp_client_secret(self) -> str | None:
180
+ """Client secret for frontend confirmation."""
181
+ return self._psp_client_secret
182
+
183
+ @psp_client_secret.setter
184
+ def psp_client_secret(self, value: str | None):
185
+ self._psp_client_secret = value
186
+
187
+ # Properties - Amount
188
+ @property
189
+ def amount_cents(self) -> int:
190
+ """Payment amount in cents."""
191
+ return self._amount_cents
192
+
193
+ @amount_cents.setter
194
+ def amount_cents(self, value: int):
195
+ if value < 0:
196
+ raise ValueError("amount_cents must be non-negative")
197
+ self._amount_cents = value
198
+
199
+ @property
200
+ def currency_code(self) -> str:
201
+ """ISO 4217 currency code."""
202
+ return self._currency_code
203
+
204
+ @currency_code.setter
205
+ def currency_code(self, value: str):
206
+ if not value or len(value) != 3:
207
+ raise ValueError("currency_code must be a 3-letter ISO 4217 code")
208
+ self._currency_code = value.upper()
209
+
210
+ # Properties - Payment Method
211
+ @property
212
+ def payment_method_id(self) -> str | None:
213
+ """PSP payment method ID."""
214
+ return self._payment_method_id
215
+
216
+ @payment_method_id.setter
217
+ def payment_method_id(self, value: str | None):
218
+ self._payment_method_id = value
219
+
220
+ @property
221
+ def payment_method_type(self) -> str | None:
222
+ """Payment method type (e.g., 'card', 'ach_debit')."""
223
+ return self._payment_method_type
224
+
225
+ @payment_method_type.setter
226
+ def payment_method_type(self, value: str | None):
227
+ self._payment_method_type = value
228
+
229
+ @property
230
+ def payment_method_last4(self) -> str | None:
231
+ """Last 4 digits of payment method."""
232
+ return self._payment_method_last4
233
+
234
+ @payment_method_last4.setter
235
+ def payment_method_last4(self, value: str | None):
236
+ self._payment_method_last4 = value
237
+
238
+ @property
239
+ def payment_method_brand(self) -> str | None:
240
+ """Payment method brand (e.g., 'visa', 'mastercard')."""
241
+ return self._payment_method_brand
242
+
243
+ @payment_method_brand.setter
244
+ def payment_method_brand(self, value: str | None):
245
+ self._payment_method_brand = value
246
+
247
+ # Properties - Status
248
+ @property
249
+ def status(self) -> str:
250
+ """Current payment intent status."""
251
+ return self._status
252
+
253
+ @status.setter
254
+ def status(self, value: str):
255
+ valid_statuses = [
256
+ self.STATUS_CREATED,
257
+ self.STATUS_PROCESSING,
258
+ self.STATUS_REQUIRES_ACTION,
259
+ self.STATUS_REQUIRES_CONFIRMATION,
260
+ self.STATUS_REQUIRES_PAYMENT_METHOD,
261
+ self.STATUS_SUCCEEDED,
262
+ self.STATUS_CANCELED,
263
+ self.STATUS_FAILED,
264
+ ]
265
+ if value not in valid_statuses:
266
+ raise ValueError(f"Invalid status: {value}. Must be one of {valid_statuses}")
267
+
268
+ # Record status change in history
269
+ if value != self._status:
270
+ self._status_history.append({
271
+ "from_status": self._status,
272
+ "to_status": value,
273
+ "timestamp": dt.datetime.now(dt.UTC).timestamp(),
274
+ })
275
+ self._last_status_change_utc_ts = dt.datetime.now(dt.UTC).timestamp()
276
+
277
+ self._status = value
278
+
279
+ @property
280
+ def status_history(self) -> list[Dict[str, Any]]:
281
+ """Status change history."""
282
+ return self._status_history
283
+
284
+ @status_history.setter
285
+ def status_history(self, value: list[Dict[str, Any]] | None):
286
+ self._status_history = value if isinstance(value, list) else []
287
+
288
+ @property
289
+ def last_status_change_utc_ts(self) -> float | None:
290
+ """Timestamp of last status change."""
291
+ return self._last_status_change_utc_ts
292
+
293
+ @last_status_change_utc_ts.setter
294
+ def last_status_change_utc_ts(self, value: float | None):
295
+ self._last_status_change_utc_ts = value
296
+
297
+ # Properties - Processing Details
298
+ @property
299
+ def setup_future_usage(self) -> str | None:
300
+ """Setup for future usage: 'on_session', 'off_session'."""
301
+ return self._setup_future_usage
302
+
303
+ @setup_future_usage.setter
304
+ def setup_future_usage(self, value: str | None):
305
+ if value and value not in ["on_session", "off_session"]:
306
+ raise ValueError("setup_future_usage must be 'on_session' or 'off_session'")
307
+ self._setup_future_usage = value
308
+
309
+ @property
310
+ def capture_method(self) -> str:
311
+ """Capture method: 'automatic', 'manual'."""
312
+ return self._capture_method
313
+
314
+ @capture_method.setter
315
+ def capture_method(self, value: str):
316
+ if value not in ["automatic", "manual"]:
317
+ raise ValueError("capture_method must be 'automatic' or 'manual'")
318
+ self._capture_method = value
319
+
320
+ @property
321
+ def confirmation_method(self) -> str:
322
+ """Confirmation method: 'automatic', 'manual'."""
323
+ return self._confirmation_method
324
+
325
+ @confirmation_method.setter
326
+ def confirmation_method(self, value: str):
327
+ if value not in ["automatic", "manual"]:
328
+ raise ValueError("confirmation_method must be 'automatic' or 'manual'")
329
+ self._confirmation_method = value
330
+
331
+ # Properties - Metadata
332
+ @property
333
+ def description(self) -> str | None:
334
+ """Payment description."""
335
+ return self._description
336
+
337
+ @description.setter
338
+ def description(self, value: str | None):
339
+ self._description = value
340
+
341
+ @property
342
+ def statement_descriptor(self) -> str | None:
343
+ """Statement descriptor (appears on card statement)."""
344
+ return self._statement_descriptor
345
+
346
+ @statement_descriptor.setter
347
+ def statement_descriptor(self, value: str | None):
348
+ self._statement_descriptor = value
349
+
350
+ @property
351
+ def receipt_email(self) -> str | None:
352
+ """Email for receipt."""
353
+ return self._receipt_email
354
+
355
+ @receipt_email.setter
356
+ def receipt_email(self, value: str | None):
357
+ self._receipt_email = value
358
+
359
+ # Properties - Error Tracking
360
+ @property
361
+ def error_code(self) -> str | None:
362
+ """PSP error code."""
363
+ return self._error_code
364
+
365
+ @error_code.setter
366
+ def error_code(self, value: str | None):
367
+ self._error_code = value
368
+
369
+ @property
370
+ def error_message(self) -> str | None:
371
+ """Human-readable error message."""
372
+ return self._error_message
373
+
374
+ @error_message.setter
375
+ def error_message(self, value: str | None):
376
+ self._error_message = value
377
+
378
+ @property
379
+ def error_type(self) -> str | None:
380
+ """Error type (e.g., 'card_error', 'invalid_request')."""
381
+ return self._error_type
382
+
383
+ @error_type.setter
384
+ def error_type(self, value: str | None):
385
+ self._error_type = value
386
+
387
+ # Properties - Webhooks
388
+ @property
389
+ def last_webhook_utc_ts(self) -> float | None:
390
+ """Timestamp of last webhook received."""
391
+ return self._last_webhook_utc_ts
392
+
393
+ @last_webhook_utc_ts.setter
394
+ def last_webhook_utc_ts(self, value: float | None):
395
+ self._last_webhook_utc_ts = value
396
+
397
+ @property
398
+ def webhook_count(self) -> int:
399
+ """Number of webhooks received."""
400
+ return self._webhook_count
401
+
402
+ @webhook_count.setter
403
+ def webhook_count(self, value: int):
404
+ self._webhook_count = value if value is not None else 0
405
+
406
+ # Properties - Related Records
407
+ @property
408
+ def payment_id(self) -> str | None:
409
+ """ID of settled Payment record (if succeeded)."""
410
+ return self._payment_id
411
+
412
+ @payment_id.setter
413
+ def payment_id(self, value: str | None):
414
+ self._payment_id = value
415
+
416
+ @property
417
+ def invoice_id(self) -> str | None:
418
+ """Related invoice ID."""
419
+ return self._invoice_id
420
+
421
+ @invoice_id.setter
422
+ def invoice_id(self, value: str | None):
423
+ self._invoice_id = value
424
+
425
+ @property
426
+ def subscription_id(self) -> str | None:
427
+ """Related subscription ID."""
428
+ return self._subscription_id
429
+
430
+ @subscription_id.setter
431
+ def subscription_id(self, value: str | None):
432
+ self._subscription_id = value
433
+
434
+ # Properties - Cancellation
435
+ @property
436
+ def canceled_utc_ts(self) -> float | None:
437
+ """Timestamp when canceled."""
438
+ return self._canceled_utc_ts
439
+
440
+ @canceled_utc_ts.setter
441
+ def canceled_utc_ts(self, value: float | None):
442
+ self._canceled_utc_ts = value
443
+
444
+ @property
445
+ def cancellation_reason(self) -> str | None:
446
+ """Reason for cancellation."""
447
+ return self._cancellation_reason
448
+
449
+ @cancellation_reason.setter
450
+ def cancellation_reason(self, value: str | None):
451
+ self._cancellation_reason = value
452
+
453
+ # Properties - PSP Data
454
+ @property
455
+ def psp_metadata(self) -> Dict[str, Any] | None:
456
+ """Raw PSP metadata."""
457
+ return self._psp_metadata
458
+
459
+ @psp_metadata.setter
460
+ def psp_metadata(self, value: Dict[str, Any] | None):
461
+ self._psp_metadata = value if isinstance(value, dict) else None
462
+
463
+ # Helper Methods
464
+ def is_succeeded(self) -> bool:
465
+ """Check if payment succeeded."""
466
+ return self._status == self.STATUS_SUCCEEDED
467
+
468
+ def is_failed(self) -> bool:
469
+ """Check if payment failed."""
470
+ return self._status == self.STATUS_FAILED
471
+
472
+ def is_canceled(self) -> bool:
473
+ """Check if payment was canceled."""
474
+ return self._status == self.STATUS_CANCELED
475
+
476
+ def is_processing(self) -> bool:
477
+ """Check if payment is processing."""
478
+ return self._status == self.STATUS_PROCESSING
479
+
480
+ def requires_action(self) -> bool:
481
+ """Check if payment requires user action."""
482
+ return self._status == self.STATUS_REQUIRES_ACTION
483
+
484
+ def is_pending(self) -> bool:
485
+ """Check if payment is in any pending state."""
486
+ pending_statuses = [
487
+ self.STATUS_CREATED,
488
+ self.STATUS_PROCESSING,
489
+ self.STATUS_REQUIRES_ACTION,
490
+ self.STATUS_REQUIRES_CONFIRMATION,
491
+ self.STATUS_REQUIRES_PAYMENT_METHOD,
492
+ ]
493
+ return self._status in pending_statuses
494
+
495
+ def is_terminal(self) -> bool:
496
+ """Check if payment is in a terminal state."""
497
+ terminal_statuses = [
498
+ self.STATUS_SUCCEEDED,
499
+ self.STATUS_CANCELED,
500
+ self.STATUS_FAILED,
501
+ ]
502
+ return self._status in terminal_statuses
503
+
504
+ def get_amount_dollars(self) -> float:
505
+ """Get amount in dollars."""
506
+ return self._amount_cents / 100.0
507
+
508
+ def has_error(self) -> bool:
509
+ """Check if payment has an error."""
510
+ return self._error_code is not None or self._error_message is not None
511
+
512
+ def increment_webhook_count(self):
513
+ """Increment webhook counter."""
514
+ self._webhook_count += 1
515
+ self._last_webhook_utc_ts = dt.datetime.now(dt.UTC).timestamp()
516
+
517
+ def set_error(self, error_code: str, error_message: str, error_type: str | None = None):
518
+ """Set error information."""
519
+ self._error_code = error_code
520
+ self._error_message = error_message
521
+ self._error_type = error_type
522
+
523
+ def clear_error(self):
524
+ """Clear error information."""
525
+ self._error_code = None
526
+ self._error_message = None
527
+ self._error_type = None
528
+
529
+ def mark_as_canceled(self, reason: str | None = None):
530
+ """Mark the intent as canceled."""
531
+ self.status = self.STATUS_CANCELED
532
+ self._canceled_utc_ts = dt.datetime.now(dt.UTC).timestamp()
533
+ self._cancellation_reason = reason
534
+
535
+ def get_status_duration_seconds(self) -> float | None:
536
+ """Get duration in current status (in seconds)."""
537
+ if not self._last_status_change_utc_ts:
538
+ return None
539
+ return dt.datetime.now(dt.UTC).timestamp() - self._last_status_change_utc_ts