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,557 @@
1
+ """
2
+ Geek Cafe, LLC
3
+ MIT License. See Project Root for the license information.
4
+
5
+ SubscriptionService for managing tenant subscriptions and billing.
6
+ """
7
+
8
+ from typing import Dict, Any, Optional, List
9
+ from decimal import Decimal
10
+ from boto3_assist.dynamodb.dynamodb import DynamoDB
11
+ from geek_cafe_saas_sdk.services.database_service import DatabaseService
12
+ from .tenant_service import TenantService
13
+ from geek_cafe_saas_sdk.core.service_result import ServiceResult
14
+ from geek_cafe_saas_sdk.core.service_errors import ValidationError, NotFoundError, AccessDeniedError
15
+ from geek_cafe_saas_sdk.domains.tenancy.models import Subscription
16
+ from geek_cafe_saas_sdk.domains.tenancy.models import Tenant
17
+ import datetime as dt
18
+
19
+
20
+ class SubscriptionService(DatabaseService[Subscription]):
21
+ """
22
+ Service for subscription management operations.
23
+
24
+ Handles subscription lifecycle including:
25
+ - Creating and activating subscriptions
26
+ - Subscription history tracking
27
+ - Active subscription pointer management
28
+ - Billing period management
29
+ - Payment tracking
30
+ - Cancellations
31
+
32
+ Uses active subscription pointer pattern:
33
+ - History items: PK=subscription#<id>, SK=subscription#<id>
34
+ - Active pointer: PK=tenant#<tenant_id>, SK=subscription#active
35
+
36
+ The active pointer allows O(1) lookup of current subscription
37
+ without scanning history.
38
+ """
39
+
40
+ def __init__(self, *, dynamodb: DynamoDB = None, table_name: str = None,
41
+ tenant_service: TenantService = None):
42
+ super().__init__(dynamodb=dynamodb, table_name=table_name)
43
+ # Tenant service for updating tenant plan_tier
44
+ self.tenant_service = tenant_service or TenantService(
45
+ dynamodb=dynamodb, table_name=table_name
46
+ )
47
+
48
+ def create(self, tenant_id: str, user_id: str, payload: Dict[str, Any]) -> ServiceResult[Subscription]:
49
+ """
50
+ Create a new subscription (adds to history).
51
+
52
+ Note: This creates a subscription record but does NOT make it active.
53
+ Use activate_subscription() to make it the active subscription.
54
+
55
+ Args:
56
+ tenant_id: Tenant ID this subscription belongs to
57
+ user_id: User ID creating the subscription
58
+ payload: Subscription data
59
+
60
+ Returns:
61
+ ServiceResult with Subscription
62
+ """
63
+ try:
64
+ # Validate required fields
65
+ required_fields = ['plan_code', 'price_cents']
66
+ self._validate_required_fields(payload, required_fields)
67
+
68
+ # Create subscription instance
69
+ subscription = Subscription().map(payload)
70
+ subscription.tenant_id = tenant_id
71
+ subscription.user_id = user_id
72
+ subscription.created_by_id = user_id
73
+
74
+ # Set defaults
75
+ if not subscription.currency:
76
+ subscription.currency = "USD"
77
+ if subscription.seat_count < 1:
78
+ subscription.seat_count = 1
79
+
80
+ # Prepare for save
81
+ subscription.prep_for_save()
82
+
83
+ # Save to database
84
+ return self._save_model(subscription)
85
+
86
+ except Exception as e:
87
+ return self._handle_service_exception(e, 'create_subscription',
88
+ tenant_id=tenant_id, user_id=user_id)
89
+
90
+ def activate_subscription(self, tenant_id: str, user_id: str,
91
+ payload: Dict[str, Any]) -> ServiceResult[Subscription]:
92
+ """
93
+ Create and activate a new subscription for tenant.
94
+
95
+ This is the main method for changing/starting a subscription.
96
+ It atomically:
97
+ 1. Creates subscription history record
98
+ 2. Updates active subscription pointer
99
+ 3. Updates tenant plan_tier
100
+
101
+ Args:
102
+ tenant_id: Tenant ID
103
+ user_id: User ID performing action
104
+ payload: Subscription data (plan_code, price_cents, etc.)
105
+
106
+ Returns:
107
+ ServiceResult with new active Subscription
108
+ """
109
+ try:
110
+ # Validate required fields
111
+ required_fields = ['plan_code', 'price_cents', 'current_period_start_utc_ts',
112
+ 'current_period_end_utc_ts']
113
+ self._validate_required_fields(payload, required_fields)
114
+
115
+ # Create subscription
116
+ subscription = Subscription().map(payload)
117
+ subscription.tenant_id = tenant_id
118
+ subscription.user_id = user_id
119
+ subscription.created_by_id = user_id
120
+
121
+ # Set defaults
122
+ if not subscription.status:
123
+ subscription.status = "active"
124
+ if not subscription.currency:
125
+ subscription.currency = "USD"
126
+ if subscription.seat_count < 1:
127
+ subscription.seat_count = 1
128
+
129
+ # Prepare for save
130
+ subscription.prep_for_save()
131
+
132
+ # TODO: Use TransactWrite for atomic operation
133
+ # For now, do sequential writes (not ideal but functional)
134
+
135
+ # 1. Save subscription history
136
+ save_result = self._save_model(subscription)
137
+ if not save_result.success:
138
+ return save_result
139
+
140
+ # 2. Update active pointer
141
+ pointer_result = self._update_active_pointer(tenant_id, subscription.id, user_id)
142
+ if not pointer_result.success:
143
+ # TODO: Rollback subscription creation
144
+ return ServiceResult(
145
+ success=False,
146
+ message=f"Failed to update active pointer: {pointer_result.message}",
147
+ error_code="POINTER_UPDATE_FAILED"
148
+ )
149
+
150
+ # 3. Update tenant plan_tier
151
+ plan_tier = self._map_plan_code_to_tier(subscription.plan_code)
152
+ tenant_result = self.tenant_service.update(
153
+ tenant_id, tenant_id, user_id,
154
+ {"plan_tier": plan_tier}
155
+ )
156
+ if not tenant_result.success:
157
+ # Log error but don't fail - subscription is already active
158
+ pass
159
+
160
+ return save_result
161
+
162
+ except Exception as e:
163
+ return self._handle_service_exception(e, 'activate_subscription',
164
+ tenant_id=tenant_id, user_id=user_id)
165
+
166
+ def _update_active_pointer(self, tenant_id: str, subscription_id: str,
167
+ user_id: str) -> ServiceResult[Dict[str, Any]]:
168
+ """
169
+ Update active subscription pointer for tenant.
170
+
171
+ Creates/updates a special pointer item:
172
+ PK: tenant#<tenant_id>
173
+ SK: subscription#active
174
+
175
+ Args:
176
+ tenant_id: Tenant ID
177
+ subscription_id: Subscription ID to point to
178
+ user_id: User performing update
179
+
180
+ Returns:
181
+ ServiceResult with pointer item data
182
+ """
183
+ try:
184
+ # Build pointer item
185
+ from boto3_assist.dynamodb.dynamodb_key import DynamoDBKey
186
+
187
+ pk = DynamoDBKey.build_key(("tenant", tenant_id))
188
+ sk = "subscription#active"
189
+
190
+ now = dt.datetime.now(dt.UTC).timestamp()
191
+
192
+ pointer_item = {
193
+ "pk": pk,
194
+ "sk": sk,
195
+ "active_subscription_id": subscription_id,
196
+ "updated_utc_ts": Decimal(str(now)), # Convert to Decimal for DynamoDB
197
+ "updated_by_id": user_id
198
+ }
199
+
200
+ # Put item
201
+ self.dynamodb.resource.Table(self.table_name).put_item(Item=pointer_item)
202
+
203
+ return ServiceResult.success_result(pointer_item)
204
+
205
+ except Exception as e:
206
+ return ServiceResult(
207
+ success=False,
208
+ message=f"Failed to update active pointer: {str(e)}",
209
+ error_code="POINTER_UPDATE_ERROR"
210
+ )
211
+
212
+ def _map_plan_code_to_tier(self, plan_code: str) -> str:
213
+ """Map plan code to tenant plan_tier."""
214
+ mapping = {
215
+ "free": "free",
216
+ "basic": "basic",
217
+ "basic_monthly": "basic",
218
+ "basic_yearly": "basic",
219
+ "pro": "pro",
220
+ "pro_monthly": "pro",
221
+ "pro_yearly": "pro",
222
+ "enterprise": "enterprise"
223
+ }
224
+ return mapping.get(plan_code, "free")
225
+
226
+ def get_active_subscription(self, tenant_id: str, user_id: str) -> ServiceResult[Subscription]:
227
+ """
228
+ Get active subscription for tenant (O(1) lookup via pointer).
229
+
230
+ Args:
231
+ tenant_id: Tenant ID
232
+ user_id: Requesting user ID
233
+
234
+ Returns:
235
+ ServiceResult with active Subscription, or None if no active subscription
236
+ """
237
+ try:
238
+ # Get pointer item
239
+ from boto3_assist.dynamodb.dynamodb_key import DynamoDBKey
240
+
241
+ pk = DynamoDBKey.build_key(("tenant", tenant_id))
242
+ sk = "subscription#active"
243
+
244
+ response = self.dynamodb.resource.Table(self.table_name).get_item(
245
+ Key={"pk": pk, "sk": sk}
246
+ )
247
+
248
+ if "Item" not in response:
249
+ # No active subscription
250
+ return ServiceResult.success_result(None)
251
+
252
+ pointer_item = response["Item"]
253
+ active_sub_id = pointer_item.get("active_subscription_id")
254
+
255
+ if not active_sub_id:
256
+ return ServiceResult.success_result(None)
257
+
258
+ # Get the actual subscription
259
+ return self.get_by_id(active_sub_id, tenant_id, user_id)
260
+
261
+ except Exception as e:
262
+ return self._handle_service_exception(e, 'get_active_subscription',
263
+ tenant_id=tenant_id)
264
+
265
+ def get_by_id(self, resource_id: str, tenant_id: str, user_id: str) -> ServiceResult[Subscription]:
266
+ """
267
+ Get subscription by ID with access control.
268
+
269
+ Args:
270
+ resource_id: Subscription ID
271
+ tenant_id: Requesting tenant ID
272
+ user_id: Requesting user ID
273
+
274
+ Returns:
275
+ ServiceResult with Subscription
276
+ """
277
+ try:
278
+ subscription = self._get_model_by_id(resource_id, Subscription)
279
+
280
+ if not subscription:
281
+ raise NotFoundError(f"Subscription with ID {resource_id} not found")
282
+
283
+ # Check if deleted
284
+ if subscription.is_deleted():
285
+ raise NotFoundError(f"Subscription with ID {resource_id} not found")
286
+
287
+ # Validate tenant access
288
+ if subscription.tenant_id != tenant_id:
289
+ raise AccessDeniedError("You don't have access to this subscription")
290
+
291
+ return ServiceResult.success_result(subscription)
292
+
293
+ except Exception as e:
294
+ return self._handle_service_exception(e, 'get_subscription',
295
+ resource_id=resource_id, tenant_id=tenant_id)
296
+
297
+ def list_subscription_history(self, tenant_id: str, user_id: str,
298
+ limit: int = 50) -> ServiceResult[List[Subscription]]:
299
+ """
300
+ List subscription history for tenant (newest first).
301
+
302
+ Uses GSI1 to query all subscriptions for a tenant, sorted by period start date.
303
+
304
+ Args:
305
+ tenant_id: Tenant ID
306
+ user_id: Requesting user ID
307
+ limit: Maximum number of results
308
+
309
+ Returns:
310
+ ServiceResult with list of Subscriptions
311
+ """
312
+ try:
313
+ # Create temp subscription for GSI1 query
314
+ temp_sub = Subscription()
315
+ temp_sub.tenant_id = tenant_id
316
+
317
+ result = self._query_by_index(
318
+ temp_sub,
319
+ "gsi1",
320
+ ascending=False, # Newest first
321
+ limit=limit
322
+ )
323
+
324
+ if not result.success:
325
+ return result
326
+
327
+ # Filter out deleted subscriptions
328
+ active_subs = [s for s in result.data if not s.is_deleted()]
329
+
330
+ return ServiceResult.success_result(active_subs)
331
+
332
+ except Exception as e:
333
+ return self._handle_service_exception(e, 'list_subscription_history',
334
+ tenant_id=tenant_id)
335
+
336
+ def cancel_subscription(self, subscription_id: str, tenant_id: str, user_id: str,
337
+ reason: Optional[str] = None, immediate: bool = False) -> ServiceResult[Subscription]:
338
+ """
339
+ Cancel a subscription.
340
+
341
+ Args:
342
+ subscription_id: Subscription ID to cancel
343
+ tenant_id: Tenant ID (for access control)
344
+ user_id: User performing cancellation
345
+ reason: Optional cancellation reason
346
+ immediate: If True, cancel immediately; if False, cancel at period end
347
+
348
+ Returns:
349
+ ServiceResult with canceled Subscription
350
+ """
351
+ try:
352
+ # Get subscription
353
+ get_result = self.get_by_id(subscription_id, tenant_id, user_id)
354
+ if not get_result.success:
355
+ return get_result
356
+
357
+ subscription = get_result.data
358
+
359
+ # Cancel
360
+ subscription.cancel(reason=reason, immediate=immediate)
361
+ subscription.updated_by_id = user_id
362
+ subscription.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
363
+ subscription.version += 1
364
+
365
+ # Save
366
+ save_result = self._save_model(subscription)
367
+
368
+ if save_result.success and immediate:
369
+ # If immediate cancellation, could remove active pointer or set to free plan
370
+ # For now, we'll leave the pointer but mark subscription as canceled
371
+ pass
372
+
373
+ return save_result
374
+
375
+ except Exception as e:
376
+ return self._handle_service_exception(e, 'cancel_subscription',
377
+ subscription_id=subscription_id, tenant_id=tenant_id)
378
+
379
+ def record_payment(self, subscription_id: str, tenant_id: str, user_id: str,
380
+ amount_cents: int) -> ServiceResult[Subscription]:
381
+ """
382
+ Record a successful payment for subscription.
383
+
384
+ Args:
385
+ subscription_id: Subscription ID
386
+ tenant_id: Tenant ID (for access control)
387
+ user_id: User recording payment
388
+ amount_cents: Payment amount in cents
389
+
390
+ Returns:
391
+ ServiceResult with updated Subscription
392
+ """
393
+ try:
394
+ # Get subscription
395
+ get_result = self.get_by_id(subscription_id, tenant_id, user_id)
396
+ if not get_result.success:
397
+ return get_result
398
+
399
+ subscription = get_result.data
400
+
401
+ # Record payment
402
+ subscription.record_payment(amount_cents)
403
+ subscription.updated_by_id = user_id
404
+ subscription.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
405
+ subscription.version += 1
406
+
407
+ # Save
408
+ return self._save_model(subscription)
409
+
410
+ except Exception as e:
411
+ return self._handle_service_exception(e, 'record_payment',
412
+ subscription_id=subscription_id, tenant_id=tenant_id)
413
+
414
+ def mark_past_due(self, subscription_id: str, tenant_id: str, user_id: str) -> ServiceResult[Subscription]:
415
+ """
416
+ Mark subscription as past due (payment failed).
417
+
418
+ Args:
419
+ subscription_id: Subscription ID
420
+ tenant_id: Tenant ID (for access control)
421
+ user_id: User marking as past due
422
+
423
+ Returns:
424
+ ServiceResult with updated Subscription
425
+ """
426
+ try:
427
+ # Get subscription
428
+ get_result = self.get_by_id(subscription_id, tenant_id, user_id)
429
+ if not get_result.success:
430
+ return get_result
431
+
432
+ subscription = get_result.data
433
+
434
+ # Mark past due
435
+ subscription.mark_past_due()
436
+ subscription.updated_by_id = user_id
437
+ subscription.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
438
+ subscription.version += 1
439
+
440
+ # Save
441
+ return self._save_model(subscription)
442
+
443
+ except Exception as e:
444
+ return self._handle_service_exception(e, 'mark_past_due',
445
+ subscription_id=subscription_id, tenant_id=tenant_id)
446
+
447
+ def list_subscriptions_by_status(self, status: str, limit: int = 50) -> ServiceResult[List[Subscription]]:
448
+ """
449
+ List subscriptions by status (for billing jobs/admin).
450
+
451
+ Uses GSI2 to query subscriptions by status, sorted by next_billing_date.
452
+ This is useful for background jobs that process billing.
453
+
454
+ Args:
455
+ status: Subscription status (trial|active|past_due|canceled|expired)
456
+ limit: Maximum number of results
457
+
458
+ Returns:
459
+ ServiceResult with list of Subscriptions
460
+ """
461
+ try:
462
+ # Create temp subscription for GSI2 query
463
+ temp_sub = Subscription()
464
+ temp_sub.status = status
465
+
466
+ result = self._query_by_index(
467
+ temp_sub,
468
+ "gsi2",
469
+ ascending=True, # Earliest next billing first
470
+ limit=limit
471
+ )
472
+
473
+ if not result.success:
474
+ return result
475
+
476
+ # Filter out deleted subscriptions
477
+ active_subs = [s for s in result.data if not s.is_deleted()]
478
+
479
+ return ServiceResult.success_result(active_subs)
480
+
481
+ except Exception as e:
482
+ return self._handle_service_exception(e, 'list_subscriptions_by_status',
483
+ status=status)
484
+
485
+ def update(self, resource_id: str, tenant_id: str, user_id: str,
486
+ updates: Dict[str, Any]) -> ServiceResult[Subscription]:
487
+ """
488
+ Update subscription.
489
+
490
+ Args:
491
+ resource_id: Subscription ID to update
492
+ tenant_id: Tenant ID (for access control)
493
+ user_id: User ID making update
494
+ updates: Fields to update
495
+
496
+ Returns:
497
+ ServiceResult with updated Subscription
498
+ """
499
+ try:
500
+ # Get subscription
501
+ get_result = self.get_by_id(resource_id, tenant_id, user_id)
502
+ if not get_result.success:
503
+ return get_result
504
+
505
+ subscription = get_result.data
506
+
507
+ # Update fields
508
+ subscription.map(updates)
509
+ subscription.updated_by_id = user_id
510
+ subscription.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
511
+ subscription.version += 1
512
+
513
+ # Save
514
+ return self._save_model(subscription)
515
+
516
+ except Exception as e:
517
+ return self._handle_service_exception(e, 'update_subscription',
518
+ resource_id=resource_id, tenant_id=tenant_id)
519
+
520
+ def delete(self, resource_id: str, tenant_id: str, user_id: str) -> ServiceResult[bool]:
521
+ """
522
+ Soft delete subscription.
523
+
524
+ Args:
525
+ resource_id: Subscription ID to delete
526
+ tenant_id: Tenant ID (for access control)
527
+ user_id: User ID performing delete
528
+
529
+ Returns:
530
+ ServiceResult with boolean (True if deleted)
531
+ """
532
+ try:
533
+ # Get subscription
534
+ get_result = self.get_by_id(resource_id, tenant_id, user_id)
535
+ if not get_result.success:
536
+ return ServiceResult(success=False, message=get_result.message,
537
+ error_code=get_result.error_code)
538
+
539
+ subscription = get_result.data
540
+
541
+ # Soft delete
542
+ subscription.deleted_utc_ts = dt.datetime.now(dt.UTC).timestamp()
543
+ subscription.deleted_by_id = user_id
544
+ subscription.updated_by_id = user_id
545
+ subscription.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
546
+
547
+ # Save
548
+ save_result = self._save_model(subscription)
549
+ if not save_result.success:
550
+ return ServiceResult(success=False, message=save_result.message,
551
+ error_code=save_result.error_code)
552
+
553
+ return ServiceResult.success_result(True)
554
+
555
+ except Exception as e:
556
+ return self._handle_service_exception(e, 'delete_subscription',
557
+ resource_id=resource_id, tenant_id=tenant_id)