geek-cafe-saas-sdk 0.6.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.

Potentially problematic release.


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

Files changed (194) hide show
  1. geek_cafe_saas_sdk/__init__.py +9 -0
  2. geek_cafe_saas_sdk/core/__init__.py +11 -0
  3. geek_cafe_saas_sdk/core/audit_mixin.py +33 -0
  4. geek_cafe_saas_sdk/core/error_codes.py +132 -0
  5. geek_cafe_saas_sdk/core/service_errors.py +19 -0
  6. geek_cafe_saas_sdk/core/service_result.py +121 -0
  7. geek_cafe_saas_sdk/decorators/__init__.py +64 -0
  8. geek_cafe_saas_sdk/decorators/auth.py +373 -0
  9. geek_cafe_saas_sdk/decorators/core.py +358 -0
  10. geek_cafe_saas_sdk/domains/__init__.py +0 -0
  11. geek_cafe_saas_sdk/domains/analytics/__init__.py +0 -0
  12. geek_cafe_saas_sdk/domains/analytics/handlers/__init__.py +0 -0
  13. geek_cafe_saas_sdk/domains/analytics/models/__init__.py +9 -0
  14. geek_cafe_saas_sdk/domains/analytics/models/website_analytics.py +219 -0
  15. geek_cafe_saas_sdk/domains/analytics/models/website_analytics_summary.py +220 -0
  16. geek_cafe_saas_sdk/domains/analytics/services/__init__.py +11 -0
  17. geek_cafe_saas_sdk/domains/analytics/services/website_analytics_service.py +232 -0
  18. geek_cafe_saas_sdk/domains/analytics/services/website_analytics_summary_service.py +212 -0
  19. geek_cafe_saas_sdk/domains/analytics/services/website_analytics_tally_service.py +610 -0
  20. geek_cafe_saas_sdk/domains/auth/__init__.py +0 -0
  21. geek_cafe_saas_sdk/domains/auth/handlers/__init__.py +0 -0
  22. geek_cafe_saas_sdk/domains/auth/handlers/users/create/app.py +41 -0
  23. geek_cafe_saas_sdk/domains/auth/handlers/users/delete/app.py +41 -0
  24. geek_cafe_saas_sdk/domains/auth/handlers/users/get/app.py +39 -0
  25. geek_cafe_saas_sdk/domains/auth/handlers/users/list/app.py +36 -0
  26. geek_cafe_saas_sdk/domains/auth/handlers/users/update/app.py +44 -0
  27. geek_cafe_saas_sdk/domains/auth/models/__init__.py +13 -0
  28. geek_cafe_saas_sdk/domains/auth/models/permission.py +134 -0
  29. geek_cafe_saas_sdk/domains/auth/models/resource_permission.py +245 -0
  30. geek_cafe_saas_sdk/domains/auth/models/role.py +213 -0
  31. geek_cafe_saas_sdk/domains/auth/models/user.py +285 -0
  32. geek_cafe_saas_sdk/domains/auth/services/__init__.py +16 -0
  33. geek_cafe_saas_sdk/domains/auth/services/authorization_service.py +376 -0
  34. geek_cafe_saas_sdk/domains/auth/services/permission_registry.py +464 -0
  35. geek_cafe_saas_sdk/domains/auth/services/resource_permission_service.py +408 -0
  36. geek_cafe_saas_sdk/domains/auth/services/user_service.py +274 -0
  37. geek_cafe_saas_sdk/domains/communities/__init__.py +0 -0
  38. geek_cafe_saas_sdk/domains/communities/handlers/__init__.py +0 -0
  39. geek_cafe_saas_sdk/domains/communities/handlers/communities/create/app.py +41 -0
  40. geek_cafe_saas_sdk/domains/communities/handlers/communities/delete/app.py +41 -0
  41. geek_cafe_saas_sdk/domains/communities/handlers/communities/get/app.py +39 -0
  42. geek_cafe_saas_sdk/domains/communities/handlers/communities/list/app.py +36 -0
  43. geek_cafe_saas_sdk/domains/communities/handlers/communities/update/app.py +44 -0
  44. geek_cafe_saas_sdk/domains/communities/models/__init__.py +6 -0
  45. geek_cafe_saas_sdk/domains/communities/models/community.py +326 -0
  46. geek_cafe_saas_sdk/domains/communities/models/community_member.py +227 -0
  47. geek_cafe_saas_sdk/domains/communities/services/__init__.py +6 -0
  48. geek_cafe_saas_sdk/domains/communities/services/community_member_service.py +412 -0
  49. geek_cafe_saas_sdk/domains/communities/services/community_service.py +479 -0
  50. geek_cafe_saas_sdk/domains/events/__init__.py +0 -0
  51. geek_cafe_saas_sdk/domains/events/handlers/__init__.py +0 -0
  52. geek_cafe_saas_sdk/domains/events/handlers/attendees/app.py +67 -0
  53. geek_cafe_saas_sdk/domains/events/handlers/cancel/app.py +66 -0
  54. geek_cafe_saas_sdk/domains/events/handlers/check_in/app.py +60 -0
  55. geek_cafe_saas_sdk/domains/events/handlers/create/app.py +93 -0
  56. geek_cafe_saas_sdk/domains/events/handlers/delete/app.py +42 -0
  57. geek_cafe_saas_sdk/domains/events/handlers/get/app.py +39 -0
  58. geek_cafe_saas_sdk/domains/events/handlers/invite/app.py +98 -0
  59. geek_cafe_saas_sdk/domains/events/handlers/list/app.py +125 -0
  60. geek_cafe_saas_sdk/domains/events/handlers/publish/app.py +49 -0
  61. geek_cafe_saas_sdk/domains/events/handlers/rsvp/app.py +83 -0
  62. geek_cafe_saas_sdk/domains/events/handlers/update/app.py +44 -0
  63. geek_cafe_saas_sdk/domains/events/models/__init__.py +3 -0
  64. geek_cafe_saas_sdk/domains/events/models/event.py +681 -0
  65. geek_cafe_saas_sdk/domains/events/models/event_attendee.py +324 -0
  66. geek_cafe_saas_sdk/domains/events/services/__init__.py +9 -0
  67. geek_cafe_saas_sdk/domains/events/services/event_attendee_service.py +571 -0
  68. geek_cafe_saas_sdk/domains/events/services/event_service.py +684 -0
  69. geek_cafe_saas_sdk/domains/files/__init__.py +0 -0
  70. geek_cafe_saas_sdk/domains/files/models/__init__.py +0 -0
  71. geek_cafe_saas_sdk/domains/files/models/directory.py +258 -0
  72. geek_cafe_saas_sdk/domains/files/models/file.py +312 -0
  73. geek_cafe_saas_sdk/domains/files/models/file_share.py +268 -0
  74. geek_cafe_saas_sdk/domains/files/models/file_version.py +216 -0
  75. geek_cafe_saas_sdk/domains/files/services/__init__.py +0 -0
  76. geek_cafe_saas_sdk/domains/files/services/directory_service.py +701 -0
  77. geek_cafe_saas_sdk/domains/files/services/file_share_service.py +663 -0
  78. geek_cafe_saas_sdk/domains/files/services/file_system_service.py +575 -0
  79. geek_cafe_saas_sdk/domains/files/services/file_version_service.py +739 -0
  80. geek_cafe_saas_sdk/domains/files/services/s3_file_service.py +501 -0
  81. geek_cafe_saas_sdk/domains/messaging/__init__.py +0 -0
  82. geek_cafe_saas_sdk/domains/messaging/handlers/__init__.py +0 -0
  83. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/create/app.py +86 -0
  84. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/delete/app.py +65 -0
  85. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/get/app.py +64 -0
  86. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/list/app.py +97 -0
  87. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/update/app.py +149 -0
  88. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/create/app.py +67 -0
  89. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/delete/app.py +65 -0
  90. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/get/app.py +64 -0
  91. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/list/app.py +102 -0
  92. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/update/app.py +127 -0
  93. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/create/app.py +94 -0
  94. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/delete/app.py +66 -0
  95. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/get/app.py +67 -0
  96. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/list/app.py +95 -0
  97. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/update/app.py +156 -0
  98. geek_cafe_saas_sdk/domains/messaging/models/__init__.py +13 -0
  99. geek_cafe_saas_sdk/domains/messaging/models/chat_channel.py +337 -0
  100. geek_cafe_saas_sdk/domains/messaging/models/chat_channel_member.py +180 -0
  101. geek_cafe_saas_sdk/domains/messaging/models/chat_message.py +426 -0
  102. geek_cafe_saas_sdk/domains/messaging/models/contact_thread.py +392 -0
  103. geek_cafe_saas_sdk/domains/messaging/services/__init__.py +11 -0
  104. geek_cafe_saas_sdk/domains/messaging/services/chat_channel_service.py +700 -0
  105. geek_cafe_saas_sdk/domains/messaging/services/chat_message_service.py +491 -0
  106. geek_cafe_saas_sdk/domains/messaging/services/contact_thread_service.py +497 -0
  107. geek_cafe_saas_sdk/domains/tenancy/__init__.py +0 -0
  108. geek_cafe_saas_sdk/domains/tenancy/handlers/__init__.py +0 -0
  109. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/activate/app.py +52 -0
  110. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/active/app.py +37 -0
  111. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/cancel/app.py +55 -0
  112. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/get/app.py +39 -0
  113. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/list/app.py +44 -0
  114. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/record_payment/app.py +56 -0
  115. geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/get/app.py +39 -0
  116. geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/me/app.py +37 -0
  117. geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/signup/app.py +61 -0
  118. geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/update/app.py +44 -0
  119. geek_cafe_saas_sdk/domains/tenancy/models/__init__.py +6 -0
  120. geek_cafe_saas_sdk/domains/tenancy/models/subscription.py +440 -0
  121. geek_cafe_saas_sdk/domains/tenancy/models/tenant.py +258 -0
  122. geek_cafe_saas_sdk/domains/tenancy/services/__init__.py +6 -0
  123. geek_cafe_saas_sdk/domains/tenancy/services/subscription_service.py +557 -0
  124. geek_cafe_saas_sdk/domains/tenancy/services/tenant_service.py +575 -0
  125. geek_cafe_saas_sdk/domains/voting/__init__.py +0 -0
  126. geek_cafe_saas_sdk/domains/voting/handlers/__init__.py +0 -0
  127. geek_cafe_saas_sdk/domains/voting/handlers/votes/create/app.py +128 -0
  128. geek_cafe_saas_sdk/domains/voting/handlers/votes/delete/app.py +41 -0
  129. geek_cafe_saas_sdk/domains/voting/handlers/votes/get/app.py +39 -0
  130. geek_cafe_saas_sdk/domains/voting/handlers/votes/list/app.py +38 -0
  131. geek_cafe_saas_sdk/domains/voting/handlers/votes/summerize/README.md +3 -0
  132. geek_cafe_saas_sdk/domains/voting/handlers/votes/update/app.py +44 -0
  133. geek_cafe_saas_sdk/domains/voting/models/__init__.py +9 -0
  134. geek_cafe_saas_sdk/domains/voting/models/vote.py +231 -0
  135. geek_cafe_saas_sdk/domains/voting/models/vote_summary.py +193 -0
  136. geek_cafe_saas_sdk/domains/voting/services/__init__.py +11 -0
  137. geek_cafe_saas_sdk/domains/voting/services/vote_service.py +264 -0
  138. geek_cafe_saas_sdk/domains/voting/services/vote_summary_service.py +198 -0
  139. geek_cafe_saas_sdk/domains/voting/services/vote_tally_service.py +533 -0
  140. geek_cafe_saas_sdk/lambda_handlers/README.md +404 -0
  141. geek_cafe_saas_sdk/lambda_handlers/__init__.py +67 -0
  142. geek_cafe_saas_sdk/lambda_handlers/_base/__init__.py +25 -0
  143. geek_cafe_saas_sdk/lambda_handlers/_base/api_key_handler.py +129 -0
  144. geek_cafe_saas_sdk/lambda_handlers/_base/authorized_secure_handler.py +218 -0
  145. geek_cafe_saas_sdk/lambda_handlers/_base/base_handler.py +185 -0
  146. geek_cafe_saas_sdk/lambda_handlers/_base/handler_factory.py +256 -0
  147. geek_cafe_saas_sdk/lambda_handlers/_base/public_handler.py +53 -0
  148. geek_cafe_saas_sdk/lambda_handlers/_base/secure_handler.py +89 -0
  149. geek_cafe_saas_sdk/lambda_handlers/_base/service_pool.py +94 -0
  150. geek_cafe_saas_sdk/lambda_handlers/directories/create/app.py +79 -0
  151. geek_cafe_saas_sdk/lambda_handlers/directories/delete/app.py +76 -0
  152. geek_cafe_saas_sdk/lambda_handlers/directories/get/app.py +74 -0
  153. geek_cafe_saas_sdk/lambda_handlers/directories/list/app.py +75 -0
  154. geek_cafe_saas_sdk/lambda_handlers/directories/move/app.py +79 -0
  155. geek_cafe_saas_sdk/lambda_handlers/files/delete/app.py +121 -0
  156. geek_cafe_saas_sdk/lambda_handlers/files/download/app.py +187 -0
  157. geek_cafe_saas_sdk/lambda_handlers/files/get/app.py +127 -0
  158. geek_cafe_saas_sdk/lambda_handlers/files/list/app.py +108 -0
  159. geek_cafe_saas_sdk/lambda_handlers/files/share/app.py +83 -0
  160. geek_cafe_saas_sdk/lambda_handlers/files/shares/list/app.py +84 -0
  161. geek_cafe_saas_sdk/lambda_handlers/files/shares/revoke/app.py +76 -0
  162. geek_cafe_saas_sdk/lambda_handlers/files/update/app.py +143 -0
  163. geek_cafe_saas_sdk/lambda_handlers/files/upload/app.py +151 -0
  164. geek_cafe_saas_sdk/middleware/__init__.py +36 -0
  165. geek_cafe_saas_sdk/middleware/auth.py +85 -0
  166. geek_cafe_saas_sdk/middleware/authorization.py +523 -0
  167. geek_cafe_saas_sdk/middleware/cors.py +63 -0
  168. geek_cafe_saas_sdk/middleware/error_handling.py +114 -0
  169. geek_cafe_saas_sdk/middleware/validation.py +80 -0
  170. geek_cafe_saas_sdk/models/__init__.py +20 -0
  171. geek_cafe_saas_sdk/models/base_model.py +233 -0
  172. geek_cafe_saas_sdk/services/__init__.py +18 -0
  173. geek_cafe_saas_sdk/services/database_service.py +441 -0
  174. geek_cafe_saas_sdk/utilities/__init__.py +88 -0
  175. geek_cafe_saas_sdk/utilities/cognito_utility.py +568 -0
  176. geek_cafe_saas_sdk/utilities/custom_exceptions.py +183 -0
  177. geek_cafe_saas_sdk/utilities/datetime_utility.py +410 -0
  178. geek_cafe_saas_sdk/utilities/dictionary_utility.py +78 -0
  179. geek_cafe_saas_sdk/utilities/dynamodb_utils.py +151 -0
  180. geek_cafe_saas_sdk/utilities/environment_loader.py +149 -0
  181. geek_cafe_saas_sdk/utilities/environment_variables.py +228 -0
  182. geek_cafe_saas_sdk/utilities/http_body_parameters.py +44 -0
  183. geek_cafe_saas_sdk/utilities/http_path_parameters.py +60 -0
  184. geek_cafe_saas_sdk/utilities/http_status_code.py +63 -0
  185. geek_cafe_saas_sdk/utilities/jwt_utility.py +234 -0
  186. geek_cafe_saas_sdk/utilities/lambda_event_utility.py +776 -0
  187. geek_cafe_saas_sdk/utilities/logging_utility.py +64 -0
  188. geek_cafe_saas_sdk/utilities/message_query_helper.py +340 -0
  189. geek_cafe_saas_sdk/utilities/response.py +209 -0
  190. geek_cafe_saas_sdk/utilities/string_functions.py +180 -0
  191. geek_cafe_saas_sdk-0.6.0.dist-info/METADATA +397 -0
  192. geek_cafe_saas_sdk-0.6.0.dist-info/RECORD +194 -0
  193. geek_cafe_saas_sdk-0.6.0.dist-info/WHEEL +4 -0
  194. geek_cafe_saas_sdk-0.6.0.dist-info/licenses/LICENSE +47 -0
@@ -0,0 +1,440 @@
1
+ """
2
+ Geek Cafe, LLC
3
+ MIT License. See Project Root for the license information.
4
+
5
+ Subscription model for tenant billing and plan management.
6
+ """
7
+
8
+ import time
9
+ from boto3_assist.dynamodb.dynamodb_index import DynamoDBIndex, DynamoDBKey
10
+ from typing import Dict, Any, Optional
11
+ from datetime import datetime, timezone
12
+ from geek_cafe_saas_sdk.models.base_model import BaseModel
13
+
14
+
15
+ class Subscription(BaseModel):
16
+ """
17
+ Subscription/billing model for tenant plans.
18
+
19
+ Tracks subscription history with one active subscription per tenant.
20
+ Uses date-sorted SK in GSI1 to maintain sortable history.
21
+
22
+ Key Features:
23
+ - Full billing history (all subscriptions for a tenant)
24
+ - Active subscription pointer (separate item for O(1) access)
25
+ - Trial period tracking
26
+ - Billing period management
27
+ - Payment status tracking
28
+
29
+ Access Patterns:
30
+ - Get subscription by ID (primary key)
31
+ - List subscription history for tenant (GSI1, date-sorted)
32
+ - Query subscriptions by status (GSI2, for billing jobs)
33
+ - Get active subscription via pointer item
34
+
35
+ Active Subscription Pointer:
36
+ - Separate DynamoDB item with SK="subscription#active"
37
+ - Contains active_subscription_id for O(1) lookups
38
+ - Updated atomically with TransactWrite when subscription changes
39
+ """
40
+
41
+ def __init__(self):
42
+ super().__init__()
43
+ # tenant_id inherited from BaseModel
44
+
45
+ # Subscription status
46
+ self._status: str = "trial" # trial|active|past_due|canceled|expired
47
+
48
+ # Plan details
49
+ self._plan_code: str | None = None # "free"|"basic"|"pro"|"enterprise"
50
+ self._plan_name: str | None = None # Display name
51
+ self._seat_count: int = 1 # Number of users/seats
52
+
53
+ # Pricing
54
+ self._price_cents: int = 0 # Price in cents (e.g., 2999 = $29.99)
55
+ self._currency: str = "USD"
56
+ self._billing_interval: str = "month" # month|year
57
+
58
+ # Trial period
59
+ self._trial_ends_utc_ts: float | None = None
60
+ self._is_trial: bool = False
61
+
62
+ # Billing periods
63
+ self._current_period_start_utc_ts: float | None = None
64
+ self._current_period_end_utc_ts: float | None = None
65
+
66
+ # Cancellation
67
+ self._canceled_utc_ts: float | None = None
68
+ self._cancel_at_period_end: bool = False
69
+ self._cancellation_reason: str | None = None
70
+
71
+ # Payment tracking
72
+ self._last_payment_utc_ts: float | None = None
73
+ self._last_payment_amount_cents: int | None = None
74
+ self._next_billing_utc_ts: float | None = None
75
+ self._payment_method: str | None = None # "card"|"invoice"|"paypal", etc.
76
+
77
+ # External billing integration
78
+ self._external_subscription_id: str | None = None # Stripe, Paddle, etc.
79
+ self._external_customer_id: str | None = None
80
+
81
+ # Metadata
82
+ self._notes: str | None = None
83
+
84
+ self._setup_indexes()
85
+
86
+ def _setup_indexes(self):
87
+ """Setup DynamoDB indexes for efficient subscription queries."""
88
+
89
+ # Primary index: subscription by ID
90
+ primary: DynamoDBIndex = DynamoDBIndex()
91
+ primary.name = "primary"
92
+ primary.partition_key.attribute_name = "pk"
93
+ primary.partition_key.value = lambda: DynamoDBKey.build_key(
94
+ ("subscription", self.id)
95
+ )
96
+ primary.sort_key.attribute_name = "sk"
97
+ primary.sort_key.value = lambda: DynamoDBKey.build_key(
98
+ ("subscription", self.id)
99
+ )
100
+ self.indexes.add_primary(primary)
101
+
102
+ # GSI1: Subscriptions by tenant (history sorted by period start date)
103
+ # SK includes date prefix for sortable history
104
+ gsi: DynamoDBIndex = DynamoDBIndex()
105
+ gsi.name = "gsi1"
106
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
107
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(
108
+ ("tenant", self.tenant_id)
109
+ )
110
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
111
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(
112
+ ("subscription", self._get_date_prefix()),
113
+ ("id", self.id)
114
+ )
115
+ self.indexes.add_secondary(gsi)
116
+
117
+ # GSI2: Subscriptions by status (for billing queries/jobs)
118
+ # Sorted by next_billing_date for processing queues
119
+ gsi: DynamoDBIndex = DynamoDBIndex()
120
+ gsi.name = "gsi2"
121
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
122
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(
123
+ ("subscription_status", self.status)
124
+ )
125
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
126
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(
127
+ ("next_billing", self.next_billing_utc_ts or 0)
128
+ )
129
+ self.indexes.add_secondary(gsi)
130
+
131
+ # GSI3: Subscriptions by plan code (for analytics)
132
+ gsi: DynamoDBIndex = DynamoDBIndex()
133
+ gsi.name = "gsi3"
134
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
135
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(
136
+ ("plan_code", self.plan_code or "unknown")
137
+ )
138
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
139
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(
140
+ ("ts", self.created_utc_ts)
141
+ )
142
+ self.indexes.add_secondary(gsi)
143
+
144
+ def _get_date_prefix(self) -> str | None:
145
+ """
146
+ Get date prefix for sortable history (yyyyMMdd).
147
+
148
+ Uses current_period_start_utc_ts if available, otherwise created_utc_ts.
149
+ """
150
+ timestamp = self.current_period_start_utc_ts or self.created_utc_ts
151
+ if timestamp is None or timestamp == 0:
152
+ return None
153
+ dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
154
+ return dt.strftime("%Y%m%d")
155
+
156
+ # Status
157
+ @property
158
+ def status(self) -> str:
159
+ """Subscription status."""
160
+ return self._status
161
+
162
+ @status.setter
163
+ def status(self, value: str):
164
+ valid_statuses = ["trial", "active", "past_due", "canceled", "expired"]
165
+ if value not in valid_statuses:
166
+ raise ValueError(f"Invalid status: {value}. Must be one of {valid_statuses}")
167
+ self._status = value
168
+
169
+ # Plan Code
170
+ @property
171
+ def plan_code(self) -> str | None:
172
+ """Plan identifier code."""
173
+ return self._plan_code
174
+
175
+ @plan_code.setter
176
+ def plan_code(self, value: str | None):
177
+ self._plan_code = value
178
+
179
+ # Plan Name
180
+ @property
181
+ def plan_name(self) -> str | None:
182
+ """Plan display name."""
183
+ return self._plan_name
184
+
185
+ @plan_name.setter
186
+ def plan_name(self, value: str | None):
187
+ self._plan_name = value
188
+
189
+ # Seat Count
190
+ @property
191
+ def seat_count(self) -> int:
192
+ """Number of seats/users."""
193
+ return self._seat_count
194
+
195
+ @seat_count.setter
196
+ def seat_count(self, value: int):
197
+ if value < 1:
198
+ raise ValueError("seat_count must be at least 1")
199
+ self._seat_count = value
200
+
201
+ # Price Cents
202
+ @property
203
+ def price_cents(self) -> int:
204
+ """Price in cents."""
205
+ return self._price_cents
206
+
207
+ @price_cents.setter
208
+ def price_cents(self, value: int):
209
+ if value < 0:
210
+ raise ValueError("price_cents cannot be negative")
211
+ self._price_cents = value
212
+
213
+ # Currency
214
+ @property
215
+ def currency(self) -> str:
216
+ """Currency code (e.g., USD, EUR)."""
217
+ return self._currency
218
+
219
+ @currency.setter
220
+ def currency(self, value: str):
221
+ self._currency = value.upper() if value else "USD"
222
+
223
+ # Billing Interval
224
+ @property
225
+ def billing_interval(self) -> str:
226
+ """Billing interval (month|year)."""
227
+ return self._billing_interval
228
+
229
+ @billing_interval.setter
230
+ def billing_interval(self, value: str):
231
+ if value not in ["month", "year"]:
232
+ raise ValueError("billing_interval must be 'month' or 'year'")
233
+ self._billing_interval = value
234
+
235
+ # Trial Ends
236
+ @property
237
+ def trial_ends_utc_ts(self) -> float | None:
238
+ """Trial period end timestamp."""
239
+ return self._trial_ends_utc_ts
240
+
241
+ @trial_ends_utc_ts.setter
242
+ def trial_ends_utc_ts(self, value: float | None):
243
+ self._trial_ends_utc_ts = value
244
+
245
+ # Is Trial
246
+ @property
247
+ def is_trial(self) -> bool:
248
+ """Whether subscription is in trial period."""
249
+ return self._is_trial
250
+
251
+ @is_trial.setter
252
+ def is_trial(self, value: bool):
253
+ self._is_trial = value
254
+
255
+ # Current Period Start
256
+ @property
257
+ def current_period_start_utc_ts(self) -> float | None:
258
+ """Current billing period start timestamp."""
259
+ return self._current_period_start_utc_ts
260
+
261
+ @current_period_start_utc_ts.setter
262
+ def current_period_start_utc_ts(self, value: float | None):
263
+ self._current_period_start_utc_ts = value
264
+
265
+ # Current Period End
266
+ @property
267
+ def current_period_end_utc_ts(self) -> float | None:
268
+ """Current billing period end timestamp."""
269
+ return self._current_period_end_utc_ts
270
+
271
+ @current_period_end_utc_ts.setter
272
+ def current_period_end_utc_ts(self, value: float | None):
273
+ self._current_period_end_utc_ts = value
274
+
275
+ # Canceled
276
+ @property
277
+ def canceled_utc_ts(self) -> float | None:
278
+ """Cancellation timestamp."""
279
+ return self._canceled_utc_ts
280
+
281
+ @canceled_utc_ts.setter
282
+ def canceled_utc_ts(self, value: float | None):
283
+ self._canceled_utc_ts = value
284
+
285
+ # Cancel at Period End
286
+ @property
287
+ def cancel_at_period_end(self) -> bool:
288
+ """Whether to cancel at end of current period."""
289
+ return self._cancel_at_period_end
290
+
291
+ @cancel_at_period_end.setter
292
+ def cancel_at_period_end(self, value: bool):
293
+ self._cancel_at_period_end = value
294
+
295
+ # Cancellation Reason
296
+ @property
297
+ def cancellation_reason(self) -> str | None:
298
+ """Reason for cancellation."""
299
+ return self._cancellation_reason
300
+
301
+ @cancellation_reason.setter
302
+ def cancellation_reason(self, value: str | None):
303
+ self._cancellation_reason = value
304
+
305
+ # Last Payment
306
+ @property
307
+ def last_payment_utc_ts(self) -> float | None:
308
+ """Last successful payment timestamp."""
309
+ return self._last_payment_utc_ts
310
+
311
+ @last_payment_utc_ts.setter
312
+ def last_payment_utc_ts(self, value: float | None):
313
+ self._last_payment_utc_ts = value
314
+
315
+ # Last Payment Amount
316
+ @property
317
+ def last_payment_amount_cents(self) -> int | None:
318
+ """Last payment amount in cents."""
319
+ return self._last_payment_amount_cents
320
+
321
+ @last_payment_amount_cents.setter
322
+ def last_payment_amount_cents(self, value: int | None):
323
+ self._last_payment_amount_cents = value
324
+
325
+ # Next Billing
326
+ @property
327
+ def next_billing_utc_ts(self) -> float | None:
328
+ """Next billing date timestamp."""
329
+ return self._next_billing_utc_ts
330
+
331
+ @next_billing_utc_ts.setter
332
+ def next_billing_utc_ts(self, value: float | None):
333
+ self._next_billing_utc_ts = value
334
+
335
+ # Payment Method
336
+ @property
337
+ def payment_method(self) -> str | None:
338
+ """Payment method type."""
339
+ return self._payment_method
340
+
341
+ @payment_method.setter
342
+ def payment_method(self, value: str | None):
343
+ self._payment_method = value
344
+
345
+ # External Subscription ID
346
+ @property
347
+ def external_subscription_id(self) -> str | None:
348
+ """External billing provider subscription ID."""
349
+ return self._external_subscription_id
350
+
351
+ @external_subscription_id.setter
352
+ def external_subscription_id(self, value: str | None):
353
+ self._external_subscription_id = value
354
+
355
+ # External Customer ID
356
+ @property
357
+ def external_customer_id(self) -> str | None:
358
+ """External billing provider customer ID."""
359
+ return self._external_customer_id
360
+
361
+ @external_customer_id.setter
362
+ def external_customer_id(self, value: str | None):
363
+ self._external_customer_id = value
364
+
365
+ # Notes
366
+ @property
367
+ def notes(self) -> str | None:
368
+ """Internal notes about subscription."""
369
+ return self._notes
370
+
371
+ @notes.setter
372
+ def notes(self, value: str | None):
373
+ self._notes = value
374
+
375
+ # Helper Methods
376
+
377
+ def is_active(self) -> bool:
378
+ """Check if subscription is active."""
379
+ return self._status == "active"
380
+
381
+ def is_trial_active(self) -> bool:
382
+ """Check if subscription is in active trial."""
383
+ return self._status == "trial"
384
+
385
+ def is_canceled(self) -> bool:
386
+ """Check if subscription is canceled."""
387
+ return self._status == "canceled"
388
+
389
+ def is_past_due(self) -> bool:
390
+ """Check if subscription payment is past due."""
391
+ return self._status == "past_due"
392
+
393
+ def is_expired(self) -> bool:
394
+ """Check if subscription is expired."""
395
+ return self._status == "expired"
396
+
397
+ def cancel(self, reason: str | None = None, immediate: bool = False):
398
+ """
399
+ Cancel subscription.
400
+
401
+ Args:
402
+ reason: Optional cancellation reason
403
+ immediate: If True, cancel immediately; if False, cancel at period end
404
+ """
405
+ import datetime as dt
406
+
407
+ self._status = "canceled"
408
+ self._canceled_utc_ts = dt.datetime.now(dt.UTC).timestamp()
409
+ self._cancellation_reason = reason
410
+
411
+ if not immediate:
412
+ self._cancel_at_period_end = True
413
+
414
+ def reactivate(self):
415
+ """Reactivate a canceled subscription."""
416
+ if self._status == "canceled":
417
+ self._status = "active"
418
+ self._canceled_utc_ts = None
419
+ self._cancel_at_period_end = False
420
+ self._cancellation_reason = None
421
+
422
+ def record_payment(self, amount_cents: int):
423
+ """Record a successful payment."""
424
+ import datetime as dt
425
+
426
+ self._last_payment_utc_ts = dt.datetime.now(dt.UTC).timestamp()
427
+ self._last_payment_amount_cents = amount_cents
428
+
429
+ # Update status if was past_due
430
+ if self._status == "past_due":
431
+ self._status = "active"
432
+
433
+ def mark_past_due(self):
434
+ """Mark subscription as past due (payment failed)."""
435
+ self._status = "past_due"
436
+
437
+ def get_price_display(self) -> str:
438
+ """Get formatted price for display."""
439
+ dollars = self._price_cents / 100
440
+ return f"${dollars:.2f} {self._currency}"
@@ -0,0 +1,258 @@
1
+ """
2
+ Geek Cafe, LLC
3
+ MIT License. See Project Root for the license information.
4
+
5
+ Tenant model for multi-tenant SaaS organizations.
6
+ """
7
+
8
+ from boto3_assist.dynamodb.dynamodb_index import DynamoDBIndex, DynamoDBKey
9
+ from typing import Dict, Any, Optional
10
+ from geek_cafe_saas_sdk.models.base_model import BaseModel
11
+
12
+
13
+ class Tenant(BaseModel):
14
+ """
15
+ Tenant/Organization model for multi-tenant SaaS.
16
+
17
+ Represents a customer organization with multiple users and a subscription.
18
+ Each tenant has a plan tier, status, and customizable feature flags.
19
+
20
+ Key Features:
21
+ - Multi-user support (one or many users per tenant)
22
+ - Subscription management (linked via tenant_id)
23
+ - Feature flags for plan differentiation
24
+ - Primary contact tracking
25
+
26
+ Access Patterns:
27
+ - Get tenant by ID (primary key)
28
+ - List tenants by status (GSI1)
29
+ - List all tenants sorted by name (GSI2)
30
+ """
31
+
32
+ def __init__(self):
33
+ super().__init__()
34
+
35
+ # Basic info
36
+ self._name: str | None = None
37
+ self._status: str = "active" # active|inactive|archived
38
+ self._plan_tier: str = "free" # free|basic|pro|enterprise
39
+
40
+ # Relationships
41
+ self._primary_contact_user_id: str | None = None
42
+
43
+ # Limits & settings
44
+ self._max_users: int | None = None # Null = unlimited
45
+ self._features: Dict[str, Any] = {} # Feature flags per tenant
46
+
47
+ # Metadata
48
+ self._description: str | None = None
49
+ self._website: str | None = None
50
+ self._logo_url: str | None = None
51
+
52
+ self._setup_indexes()
53
+
54
+ def _setup_indexes(self):
55
+ """Setup DynamoDB indexes for efficient tenant queries."""
56
+
57
+ # Primary index: tenant by ID
58
+ primary: DynamoDBIndex = DynamoDBIndex()
59
+ primary.name = "primary"
60
+ primary.partition_key.attribute_name = "pk"
61
+ primary.partition_key.value = lambda: DynamoDBKey.build_key(("tenant", self.id))
62
+ primary.sort_key.attribute_name = "sk"
63
+ primary.sort_key.value = lambda: DynamoDBKey.build_key(("tenant", self.id))
64
+ self.indexes.add_primary(primary)
65
+
66
+ # GSI1: Tenants by status (for admin queries - active, inactive, archived)
67
+ gsi: DynamoDBIndex = DynamoDBIndex()
68
+ gsi.name = "gsi1"
69
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
70
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(
71
+ ("tenant_status", self.status)
72
+ )
73
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
74
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(
75
+ ("ts", self.created_utc_ts)
76
+ )
77
+ self.indexes.add_secondary(gsi)
78
+
79
+ # GSI2: All tenants sorted by name (for admin listing)
80
+ gsi: DynamoDBIndex = DynamoDBIndex()
81
+ gsi.name = "gsi2"
82
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
83
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(("tenant", "all"))
84
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
85
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(
86
+ ("name", self.name or ""),
87
+ ("ts", self.created_utc_ts)
88
+ )
89
+ self.indexes.add_secondary(gsi)
90
+
91
+ # GSI3: Tenants by plan tier (for analytics/reporting)
92
+ gsi: DynamoDBIndex = DynamoDBIndex()
93
+ gsi.name = "gsi3"
94
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
95
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(
96
+ ("plan_tier", self.plan_tier)
97
+ )
98
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
99
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(
100
+ ("ts", self.created_utc_ts)
101
+ )
102
+ self.indexes.add_secondary(gsi)
103
+
104
+ # Name
105
+ @property
106
+ def name(self) -> str | None:
107
+ """Tenant organization name."""
108
+ return self._name
109
+
110
+ @name.setter
111
+ def name(self, value: str | None):
112
+ self._name = value
113
+
114
+ # Status
115
+ @property
116
+ def status(self) -> str:
117
+ """Tenant status (active|inactive|archived)."""
118
+ return self._status
119
+
120
+ @status.setter
121
+ def status(self, value: str):
122
+ if value not in ["active", "inactive", "archived"]:
123
+ raise ValueError(f"Invalid status: {value}. Must be active, inactive, or archived.")
124
+ self._status = value
125
+
126
+ # Plan Tier
127
+ @property
128
+ def plan_tier(self) -> str:
129
+ """Subscription plan tier (free|basic|pro|enterprise)."""
130
+ return self._plan_tier
131
+
132
+ @plan_tier.setter
133
+ def plan_tier(self, value: str):
134
+ if value not in ["free", "basic", "pro", "enterprise"]:
135
+ raise ValueError(f"Invalid plan_tier: {value}. Must be free, basic, pro, or enterprise.")
136
+ self._plan_tier = value
137
+
138
+ # Primary Contact User ID
139
+ @property
140
+ def primary_contact_user_id(self) -> str | None:
141
+ """User ID of primary contact/admin."""
142
+ return self._primary_contact_user_id
143
+
144
+ @primary_contact_user_id.setter
145
+ def primary_contact_user_id(self, value: str | None):
146
+ self._primary_contact_user_id = value
147
+
148
+ # Max Users
149
+ @property
150
+ def max_users(self) -> int | None:
151
+ """Maximum users allowed (None = unlimited)."""
152
+ return self._max_users
153
+
154
+ @max_users.setter
155
+ def max_users(self, value: int | None):
156
+ if value is not None and value < 1:
157
+ raise ValueError("max_users must be at least 1 or None for unlimited")
158
+ self._max_users = value
159
+
160
+ # Features
161
+ @property
162
+ def features(self) -> Dict[str, Any]:
163
+ """
164
+ Feature flags for tenant.
165
+
166
+ Example:
167
+ {
168
+ "chat": True,
169
+ "events": True,
170
+ "analytics": False,
171
+ "api_access": True,
172
+ "custom_branding": True
173
+ }
174
+ """
175
+ return self._features
176
+
177
+ @features.setter
178
+ def features(self, value: Dict[str, Any]):
179
+ if value is None:
180
+ self._features = {}
181
+ elif isinstance(value, dict):
182
+ self._features = value
183
+ else:
184
+ self._features = {}
185
+
186
+ # Description
187
+ @property
188
+ def description(self) -> str | None:
189
+ """Tenant description."""
190
+ return self._description
191
+
192
+ @description.setter
193
+ def description(self, value: str | None):
194
+ self._description = value
195
+
196
+ # Website
197
+ @property
198
+ def website(self) -> str | None:
199
+ """Tenant website URL."""
200
+ return self._website
201
+
202
+ @website.setter
203
+ def website(self, value: str | None):
204
+ self._website = value
205
+
206
+ # Logo URL
207
+ @property
208
+ def logo_url(self) -> str | None:
209
+ """Tenant logo URL."""
210
+ return self._logo_url
211
+
212
+ @logo_url.setter
213
+ def logo_url(self, value: str | None):
214
+ self._logo_url = value
215
+
216
+ # Helper Methods
217
+
218
+ def is_active(self) -> bool:
219
+ """Check if tenant is active."""
220
+ return self._status == "active"
221
+
222
+ def is_inactive(self) -> bool:
223
+ """Check if tenant is inactive."""
224
+ return self._status == "inactive"
225
+
226
+ def is_archived(self) -> bool:
227
+ """Check if tenant is archived."""
228
+ return self._status == "archived"
229
+
230
+ def has_feature(self, feature_key: str) -> bool:
231
+ """Check if tenant has a specific feature enabled."""
232
+ return self._features.get(feature_key, False) is True
233
+
234
+ def enable_feature(self, feature_key: str):
235
+ """Enable a feature for this tenant."""
236
+ self._features[feature_key] = True
237
+
238
+ def disable_feature(self, feature_key: str):
239
+ """Disable a feature for this tenant."""
240
+ self._features[feature_key] = False
241
+
242
+ def is_at_user_limit(self, current_user_count: int) -> bool:
243
+ """Check if tenant is at maximum user limit."""
244
+ if self._max_users is None:
245
+ return False # Unlimited
246
+ return current_user_count >= self._max_users
247
+
248
+ def activate(self):
249
+ """Set tenant status to active."""
250
+ self._status = "active"
251
+
252
+ def deactivate(self):
253
+ """Set tenant status to inactive."""
254
+ self._status = "inactive"
255
+
256
+ def archive(self):
257
+ """Set tenant status to archived."""
258
+ self._status = "archived"
@@ -0,0 +1,6 @@
1
+ # Tenancy Domain Services
2
+
3
+ from .tenant_service import TenantService
4
+ from .subscription_service import SubscriptionService
5
+
6
+ __all__ = ["TenantService", "SubscriptionService"]