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,576 @@
1
+ """
2
+ NotificationService - Multi-channel notification delivery.
3
+
4
+ Handles notification creation, delivery, state management, and user preferences.
5
+ Supports email, SMS, in-app, push, and webhook channels.
6
+
7
+ Geek Cafe, LLC
8
+ MIT License. See Project Root for the license information.
9
+ """
10
+
11
+ import datetime as dt
12
+ from typing import Optional, Dict, List, Any
13
+ from boto3_assist.dynamodb.dynamodb import DynamoDB
14
+ from boto3.dynamodb.conditions import Key
15
+ from geek_cafe_saas_sdk.core.service_result import ServiceResult
16
+ from geek_cafe_saas_sdk.core.error_codes import ErrorCode
17
+ from geek_cafe_saas_sdk.services.database_service import DatabaseService
18
+ from geek_cafe_saas_sdk.domains.notifications.models import (
19
+ Notification,
20
+ NotificationPreference,
21
+ WebhookSubscription
22
+ )
23
+
24
+
25
+ class NotificationService(DatabaseService[Notification]):
26
+ """
27
+ Service for managing notifications and multi-channel delivery.
28
+
29
+ Features:
30
+ - Multi-channel delivery (email, SMS, push, in-app, webhook)
31
+ - User preference management
32
+ - Quiet hours and DND support
33
+ - Retry logic
34
+ - State tracking
35
+ - Webhook subscriptions
36
+ """
37
+
38
+ def __init__(self, *, dynamodb: Optional[DynamoDB] = None, table_name: Optional[str] = None):
39
+ super().__init__(dynamodb=dynamodb, table_name=table_name)
40
+
41
+ # ========================================================================
42
+ # Abstract Method Implementations (Required by DatabaseService)
43
+ # ========================================================================
44
+
45
+ def create(self, tenant_id: str, user_id: str, **kwargs) -> ServiceResult[Notification]:
46
+ """Create a new notification. Delegates to create_notification."""
47
+ return self.create_notification(
48
+ tenant_id=tenant_id,
49
+ notification_type=kwargs.get('notification_type', 'general'),
50
+ channel=kwargs.get('channel', 'in_app'),
51
+ recipient_id=kwargs.get('recipient_id', user_id),
52
+ **kwargs
53
+ )
54
+
55
+ def get_by_id(self, resource_id: str, tenant_id: str, user_id: str) -> ServiceResult[Notification]:
56
+ """Get notification by ID. Delegates to get_notification."""
57
+ return self.get_notification(tenant_id=tenant_id, notification_id=resource_id)
58
+
59
+ def update(self, resource_id: str, tenant_id: str, user_id: str, updates: Dict[str, Any]) -> ServiceResult[Notification]:
60
+ """Update notification. Delegates to update_notification_state."""
61
+ return self.update_notification_state(
62
+ tenant_id=tenant_id,
63
+ notification_id=resource_id,
64
+ **updates
65
+ )
66
+
67
+ def delete(self, resource_id: str, tenant_id: str, user_id: str) -> ServiceResult[bool]:
68
+ """Delete (archive) notification by updating its state."""
69
+ try:
70
+ result = self.update_notification_state(
71
+ tenant_id=tenant_id,
72
+ notification_id=resource_id,
73
+ state='archived'
74
+ )
75
+ return ServiceResult.success_result(result.success)
76
+ except Exception as e:
77
+ return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "delete_notification")
78
+
79
+ # ========================================================================
80
+ # Notification Operations
81
+ # ========================================================================
82
+
83
+ def create_notification(
84
+ self,
85
+ tenant_id: str,
86
+ notification_type: str,
87
+ channel: str,
88
+ recipient_id: str,
89
+ body: str,
90
+ **kwargs
91
+ ) -> ServiceResult[Notification]:
92
+ """
93
+ Create a new notification.
94
+
95
+ Args:
96
+ tenant_id: Tenant ID
97
+ notification_type: Type identifier (e.g., "payment_receipt")
98
+ channel: Delivery channel (email, sms, push, in_app, webhook)
99
+ recipient_id: User ID receiving notification
100
+ body: Notification content
101
+ **kwargs: Additional fields (subject, title, template_id, etc.)
102
+
103
+ Returns:
104
+ ServiceResult with Notification
105
+ """
106
+ try:
107
+ notification = Notification().map(kwargs)
108
+ notification.tenant_id = tenant_id
109
+ notification.notification_type = notification_type
110
+ notification.channel = channel
111
+ notification.recipient_id = recipient_id
112
+ notification.body = body
113
+
114
+ # Set queued timestamp
115
+ notification.queued_utc_ts = dt.datetime.now(dt.UTC).timestamp()
116
+
117
+ # Validate
118
+ is_valid, errors = notification.validate()
119
+ if not is_valid:
120
+ return ServiceResult.error_result(
121
+ message=f"Validation failed: {', '.join(errors)}",
122
+ error_code=ErrorCode.VALIDATION_ERROR
123
+ )
124
+
125
+ # Save using helper method - automatically handles pk/sk from _setup_indexes()
126
+ notification.prep_for_save()
127
+ return self._save_model(notification)
128
+
129
+ except Exception as e:
130
+ return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "create_notification")
131
+
132
+ def get_notification(
133
+ self,
134
+ tenant_id: str,
135
+ notification_id: str
136
+ ) -> ServiceResult[Notification]:
137
+ """Get a notification by ID."""
138
+ try:
139
+ # Use helper to get notification with tenant check
140
+ notification = self._get_model_by_id_with_tenant_check(
141
+ notification_id, Notification, tenant_id
142
+ )
143
+
144
+ if not notification:
145
+ return ServiceResult.error_result(
146
+ message=f"Notification not found: {notification_id}",
147
+ error_code=ErrorCode.NOT_FOUND
148
+ )
149
+
150
+ return ServiceResult.success_result(notification)
151
+
152
+ except Exception as e:
153
+ return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "get_notification")
154
+
155
+ def update_notification_state(
156
+ self,
157
+ tenant_id: str,
158
+ notification_id: str,
159
+ state: str,
160
+ **kwargs
161
+ ) -> ServiceResult[Notification]:
162
+ """
163
+ Update notification state.
164
+
165
+ Args:
166
+ tenant_id: Tenant ID
167
+ notification_id: Notification ID
168
+ state: New state (sent, delivered, failed, etc.)
169
+ **kwargs: Additional fields (error_code, provider_message_id, etc.)
170
+
171
+ Returns:
172
+ ServiceResult with updated Notification
173
+ """
174
+ try:
175
+ result = self.get_notification(tenant_id, notification_id)
176
+ if not result.success:
177
+ return result
178
+
179
+ notification = result.data
180
+ notification = notification.map(kwargs)
181
+ notification.state = state
182
+
183
+ # Update timestamp based on state
184
+ now = dt.datetime.now(dt.UTC).timestamp()
185
+ if state == Notification.STATE_SENT:
186
+ notification.sent_utc_ts = now
187
+ elif state == Notification.STATE_DELIVERED:
188
+ notification.delivered_utc_ts = now
189
+ elif state == Notification.STATE_FAILED:
190
+ notification.failed_utc_ts = now
191
+
192
+ # Save
193
+ notification.updated_utc_ts = now
194
+ notification.version += 1
195
+ notification.prep_for_save()
196
+
197
+ return self._save_model(notification)
198
+
199
+ except Exception as e:
200
+ return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "update_notification_state")
201
+
202
+ def list_notifications(
203
+ self,
204
+ recipient_id: str,
205
+ limit: int = 50,
206
+ unread_only: bool = False
207
+ ) -> ServiceResult[List[Notification]]:
208
+ """
209
+ List notifications for a user.
210
+
211
+ Args:
212
+ recipient_id: User ID
213
+ limit: Max results
214
+ unread_only: Filter to unread notifications
215
+
216
+ Returns:
217
+ ServiceResult with list of Notifications
218
+ """
219
+ try:
220
+ # Create temp notification with recipient_id set for index query
221
+ temp_notification = Notification()
222
+ temp_notification.recipient_id = recipient_id
223
+ temp_notification.queued_utc_ts = 0 # Will match all
224
+
225
+ result = self._query_by_index(
226
+ temp_notification,
227
+ "gsi1",
228
+ ascending=False, # Most recent first
229
+ limit=limit
230
+ )
231
+
232
+ if not result.success:
233
+ return result
234
+
235
+ # Filter unread if requested
236
+ notifications = []
237
+ for notification in result.data:
238
+ if unread_only and notification.read_utc_ts is not None:
239
+ continue
240
+ notifications.append(notification)
241
+
242
+ return ServiceResult.success_result(notifications)
243
+
244
+ except Exception as e:
245
+ return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "list_notifications")
246
+
247
+ def mark_as_read(
248
+ self,
249
+ tenant_id: str,
250
+ notification_id: str
251
+ ) -> ServiceResult[Notification]:
252
+ """Mark an in-app notification as read."""
253
+ try:
254
+ result = self.get_notification(tenant_id, notification_id)
255
+ if not result.success:
256
+ return result
257
+
258
+ notification = result.data
259
+
260
+ if notification.channel == Notification.CHANNEL_IN_APP:
261
+ notification.mark_read(dt.datetime.now(dt.UTC).timestamp())
262
+ notification.prep_for_save()
263
+ return self._save_model(notification)
264
+
265
+ return ServiceResult.success_result(notification)
266
+
267
+ except Exception as e:
268
+ return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "mark_as_read")
269
+
270
+ # ========================================================================
271
+ # Preference Operations
272
+ # ========================================================================
273
+
274
+ def get_user_preferences(
275
+ self,
276
+ user_id: str
277
+ ) -> ServiceResult[NotificationPreference]:
278
+ """
279
+ Get user notification preferences.
280
+
281
+ Creates default preferences if none exist.
282
+ """
283
+ try:
284
+ preferences = NotificationPreference()
285
+ preferences.user_id = user_id
286
+
287
+ result = self.dynamodb.get(
288
+ table_name=self.table_name,
289
+ model=preferences,
290
+ )
291
+
292
+ if not result or "Item" not in result:
293
+ # Create default preferences
294
+ preferences = NotificationPreference()
295
+ preferences.user_id = user_id
296
+ return ServiceResult.success_result(preferences)
297
+
298
+ # Use .map() instead of load_from_dictionary
299
+ preferences = NotificationPreference().map(result["Item"])
300
+
301
+ return ServiceResult.success_result(preferences)
302
+
303
+ except Exception as e:
304
+ return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "get_user_preferences")
305
+
306
+ def update_preferences(
307
+ self,
308
+ user_id: str,
309
+ updates: Dict[str, Any]
310
+ ) -> ServiceResult[NotificationPreference]:
311
+ """
312
+ Update user notification preferences.
313
+
314
+ Args:
315
+ user_id: User ID
316
+ updates: Fields to update
317
+
318
+ Returns:
319
+ ServiceResult with updated NotificationPreference
320
+ """
321
+ try:
322
+ result = self.get_user_preferences(user_id)
323
+ if not result.success:
324
+ return result
325
+
326
+ preferences = result.data
327
+
328
+ # Apply updates
329
+ preferences = preferences.map(updates)
330
+
331
+ # Validate
332
+ is_valid, errors = preferences.validate()
333
+ if not is_valid:
334
+ return ServiceResult.error_result(
335
+ message=f"Validation failed: {', '.join(errors)}",
336
+ error_code=ErrorCode.VALIDATION_ERROR
337
+ )
338
+
339
+ # Save
340
+ preferences.prep_for_save()
341
+ return self._save_model(preferences)
342
+
343
+ except Exception as e:
344
+ return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "update_preferences")
345
+
346
+ def set_type_preference(
347
+ self,
348
+ user_id: str,
349
+ notification_type: str,
350
+ channel: str,
351
+ enabled: bool
352
+ ) -> ServiceResult[NotificationPreference]:
353
+ """Set preference for specific notification type and channel."""
354
+ try:
355
+ result = self.get_user_preferences(user_id)
356
+ if not result.success:
357
+ return result
358
+
359
+ preferences = result.data
360
+ preferences.set_type_preference(notification_type, channel, enabled)
361
+
362
+ # Save
363
+ preferences.prep_for_save()
364
+ return self._save_model(preferences)
365
+
366
+ except Exception as e:
367
+ return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "set_type_preference")
368
+
369
+ # ========================================================================
370
+ # Webhook Subscription Operations
371
+ # ========================================================================
372
+
373
+ def create_webhook_subscription(
374
+ self,
375
+ tenant_id: str,
376
+ subscription_name: str,
377
+ url: str,
378
+ event_types: List[str],
379
+ **kwargs
380
+ ) -> ServiceResult[WebhookSubscription]:
381
+ """
382
+ Create a webhook subscription.
383
+
384
+ Args:
385
+ tenant_id: Tenant ID
386
+ subscription_name: Display name
387
+ url: Webhook endpoint URL
388
+ event_types: List of events to subscribe to
389
+ **kwargs: Additional config (secret, headers, etc.)
390
+
391
+ Returns:
392
+ ServiceResult with WebhookSubscription
393
+ """
394
+ try:
395
+ subscription = WebhookSubscription()
396
+ # set optional fields
397
+ subscription = subscription.map(kwargs)
398
+ # set known fields
399
+ subscription.tenant_id = tenant_id
400
+ subscription.subscription_name = subscription_name
401
+ subscription.url = url
402
+ subscription.event_types = event_types
403
+
404
+
405
+
406
+
407
+ # Validate
408
+ is_valid, errors = subscription.validate()
409
+ if not is_valid:
410
+ return ServiceResult.error_result(
411
+ message=f"Validation failed: {', '.join(errors)}",
412
+ error_code=ErrorCode.VALIDATION_ERROR
413
+ )
414
+
415
+ # Save
416
+ subscription.prep_for_save()
417
+ return self._save_model(subscription)
418
+
419
+ except Exception as e:
420
+ return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "create_webhook_subscription")
421
+
422
+ def get_webhook_subscription(
423
+ self,
424
+ tenant_id: str,
425
+ subscription_id: str
426
+ ) -> ServiceResult[WebhookSubscription]:
427
+ """Get a webhook subscription by ID."""
428
+ try:
429
+ subscription = self._get_model_by_id_with_tenant_check(
430
+ subscription_id, WebhookSubscription, tenant_id
431
+ )
432
+
433
+ if not subscription:
434
+ return ServiceResult.error_result(
435
+ message=f"Webhook subscription not found: {subscription_id}",
436
+ error_code=ErrorCode.NOT_FOUND
437
+ )
438
+
439
+ return ServiceResult.success_result(subscription)
440
+
441
+ except Exception as e:
442
+ return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "get_webhook_subscription")
443
+
444
+ def update_webhook_subscription(
445
+ self,
446
+ tenant_id: str,
447
+ subscription_id: str,
448
+ updates: Dict[str, Any]
449
+ ) -> ServiceResult[WebhookSubscription]:
450
+ """Update a webhook subscription."""
451
+ try:
452
+ result = self.get_webhook_subscription(tenant_id, subscription_id)
453
+ if not result.success:
454
+ return result
455
+
456
+ subscription = result.data
457
+
458
+ # Apply updates
459
+ for key, value in updates.items():
460
+ if hasattr(subscription, key):
461
+ setattr(subscription, key, value)
462
+
463
+ # Validate
464
+ is_valid, errors = subscription.validate()
465
+ if not is_valid:
466
+ return ServiceResult.error_result(
467
+ message=f"Validation failed: {', '.join(errors)}",
468
+ error_code=ErrorCode.VALIDATION_ERROR
469
+ )
470
+
471
+ # Save
472
+ subscription.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
473
+ subscription.version += 1
474
+ subscription.prep_for_save()
475
+
476
+ return self._save_model(subscription)
477
+
478
+ except Exception as e:
479
+ return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "update_webhook_subscription")
480
+
481
+ def list_webhook_subscriptions(
482
+ self,
483
+ tenant_id: str,
484
+ active_only: bool = True
485
+ ) -> ServiceResult[List[WebhookSubscription]]:
486
+ """List webhook subscriptions for a tenant."""
487
+ try:
488
+ gsi_pk = f"tenant#{tenant_id}"
489
+
490
+ results = self.dynamodb.query(
491
+ key=Key("gsi1_pk").eq(gsi_pk) & Key("gsi1_sk").begins_with("WEBHOOK#"),
492
+ table_name=self.table_name,
493
+ index_name="gsi1"
494
+ )
495
+
496
+ subscriptions = []
497
+ for item in results.get("Items", []):
498
+ # Use .map() instead of load_from_dictionary
499
+ subscription = WebhookSubscription().map(item)
500
+
501
+ if active_only and not subscription.is_active():
502
+ continue
503
+
504
+ subscriptions.append(subscription)
505
+
506
+ return ServiceResult.success_result(subscriptions)
507
+
508
+ except Exception as e:
509
+ return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "list_webhook_subscriptions")
510
+
511
+ # ========================================================================
512
+ # Delivery Helper Methods
513
+ # ========================================================================
514
+
515
+ def should_send(
516
+ self,
517
+ notification: Notification,
518
+ preferences: NotificationPreference,
519
+ current_time: Optional[str] = None
520
+ ) -> tuple[bool, str]:
521
+ """
522
+ Check if notification should be sent based on preferences.
523
+
524
+ Returns:
525
+ Tuple of (should_send: bool, reason: str)
526
+ """
527
+ # Urgent notifications bypass all preference checks except global disable
528
+ if notification.priority == Notification.PRIORITY_URGENT:
529
+ if not preferences.enabled:
530
+ return (False, "Notifications disabled")
531
+ # Skip DND, channel preferences, type preferences, and quiet hours for urgent
532
+ # Continue to expiration and scheduling checks below
533
+ else:
534
+ # Non-urgent: apply all preference checks
535
+ if not preferences.enabled:
536
+ return (False, "Notifications disabled")
537
+
538
+ # Check DND
539
+ if preferences.do_not_disturb:
540
+ return (False, "Do not disturb enabled")
541
+
542
+ # Check channel enabled
543
+ if not preferences.is_channel_enabled(notification.channel):
544
+ return (False, f"Channel {notification.channel} disabled")
545
+
546
+ # Check type-specific preference
547
+ if not preferences.is_type_enabled(notification.notification_type, notification.channel):
548
+ return (False, f"Type {notification.notification_type} disabled for {notification.channel}")
549
+
550
+ # Check quiet hours
551
+ if current_time and preferences.is_in_quiet_hours(current_time):
552
+ return (False, "In quiet hours")
553
+
554
+ # Check expiration
555
+ now = dt.datetime.now(dt.UTC).timestamp()
556
+ if notification.is_expired(now):
557
+ return (False, "Notification expired")
558
+
559
+ # Check scheduling
560
+ if not notification.should_send_now(now):
561
+ return (False, "Scheduled for later")
562
+
563
+ return (True, "OK")
564
+
565
+ def get_unread_count(self, recipient_id: str) -> ServiceResult[int]:
566
+ """Get count of unread in-app notifications."""
567
+ try:
568
+ result = self.list_notifications(recipient_id, limit=1000, unread_only=True)
569
+ if not result.success:
570
+ return result
571
+
572
+ count = len(result.data)
573
+ return ServiceResult.success_result(count)
574
+
575
+ except Exception as e:
576
+ return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "get_unread_count")
@@ -0,0 +1,16 @@
1
+ """Payment domain.
2
+
3
+ Geek Cafe, LLC
4
+ MIT License. See Project Root for the license information.
5
+ """
6
+
7
+ from .models import BillingAccount, PaymentIntentRef, Payment, Refund
8
+ from .services import PaymentService
9
+
10
+ __all__ = [
11
+ "BillingAccount",
12
+ "PaymentIntentRef",
13
+ "Payment",
14
+ "Refund",
15
+ "PaymentService",
16
+ ]