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.
- geek_cafe_saas_sdk/__init__.py +9 -0
- geek_cafe_saas_sdk/core/__init__.py +11 -0
- geek_cafe_saas_sdk/core/audit_mixin.py +33 -0
- geek_cafe_saas_sdk/core/error_codes.py +132 -0
- geek_cafe_saas_sdk/core/service_errors.py +19 -0
- geek_cafe_saas_sdk/core/service_result.py +121 -0
- geek_cafe_saas_sdk/decorators/__init__.py +64 -0
- geek_cafe_saas_sdk/decorators/auth.py +373 -0
- geek_cafe_saas_sdk/decorators/core.py +358 -0
- geek_cafe_saas_sdk/domains/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/analytics/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/analytics/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/analytics/models/__init__.py +9 -0
- geek_cafe_saas_sdk/domains/analytics/models/website_analytics.py +219 -0
- geek_cafe_saas_sdk/domains/analytics/models/website_analytics_summary.py +220 -0
- geek_cafe_saas_sdk/domains/analytics/services/__init__.py +11 -0
- geek_cafe_saas_sdk/domains/analytics/services/website_analytics_service.py +232 -0
- geek_cafe_saas_sdk/domains/analytics/services/website_analytics_summary_service.py +212 -0
- geek_cafe_saas_sdk/domains/analytics/services/website_analytics_tally_service.py +610 -0
- geek_cafe_saas_sdk/domains/auth/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/auth/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/create/app.py +41 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/delete/app.py +41 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/list/app.py +36 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/auth/models/__init__.py +13 -0
- geek_cafe_saas_sdk/domains/auth/models/permission.py +134 -0
- geek_cafe_saas_sdk/domains/auth/models/resource_permission.py +245 -0
- geek_cafe_saas_sdk/domains/auth/models/role.py +213 -0
- geek_cafe_saas_sdk/domains/auth/models/user.py +285 -0
- geek_cafe_saas_sdk/domains/auth/services/__init__.py +16 -0
- geek_cafe_saas_sdk/domains/auth/services/authorization_service.py +376 -0
- geek_cafe_saas_sdk/domains/auth/services/permission_registry.py +464 -0
- geek_cafe_saas_sdk/domains/auth/services/resource_permission_service.py +408 -0
- geek_cafe_saas_sdk/domains/auth/services/user_service.py +274 -0
- geek_cafe_saas_sdk/domains/communities/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/communities/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/create/app.py +41 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/delete/app.py +41 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/list/app.py +36 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/communities/models/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/communities/models/community.py +326 -0
- geek_cafe_saas_sdk/domains/communities/models/community_member.py +227 -0
- geek_cafe_saas_sdk/domains/communities/services/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/communities/services/community_member_service.py +412 -0
- geek_cafe_saas_sdk/domains/communities/services/community_service.py +479 -0
- geek_cafe_saas_sdk/domains/events/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/events/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/events/handlers/attendees/app.py +67 -0
- geek_cafe_saas_sdk/domains/events/handlers/cancel/app.py +66 -0
- geek_cafe_saas_sdk/domains/events/handlers/check_in/app.py +60 -0
- geek_cafe_saas_sdk/domains/events/handlers/create/app.py +93 -0
- geek_cafe_saas_sdk/domains/events/handlers/delete/app.py +42 -0
- geek_cafe_saas_sdk/domains/events/handlers/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/events/handlers/invite/app.py +98 -0
- geek_cafe_saas_sdk/domains/events/handlers/list/app.py +125 -0
- geek_cafe_saas_sdk/domains/events/handlers/publish/app.py +49 -0
- geek_cafe_saas_sdk/domains/events/handlers/rsvp/app.py +83 -0
- geek_cafe_saas_sdk/domains/events/handlers/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/events/models/__init__.py +3 -0
- geek_cafe_saas_sdk/domains/events/models/event.py +681 -0
- geek_cafe_saas_sdk/domains/events/models/event_attendee.py +324 -0
- geek_cafe_saas_sdk/domains/events/services/__init__.py +9 -0
- geek_cafe_saas_sdk/domains/events/services/event_attendee_service.py +571 -0
- geek_cafe_saas_sdk/domains/events/services/event_service.py +684 -0
- geek_cafe_saas_sdk/domains/files/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/files/models/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/files/models/directory.py +258 -0
- geek_cafe_saas_sdk/domains/files/models/file.py +312 -0
- geek_cafe_saas_sdk/domains/files/models/file_share.py +268 -0
- geek_cafe_saas_sdk/domains/files/models/file_version.py +216 -0
- geek_cafe_saas_sdk/domains/files/services/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/files/services/directory_service.py +701 -0
- geek_cafe_saas_sdk/domains/files/services/file_share_service.py +663 -0
- geek_cafe_saas_sdk/domains/files/services/file_system_service.py +575 -0
- geek_cafe_saas_sdk/domains/files/services/file_version_service.py +739 -0
- geek_cafe_saas_sdk/domains/files/services/s3_file_service.py +501 -0
- geek_cafe_saas_sdk/domains/messaging/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/create/app.py +86 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/delete/app.py +65 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/get/app.py +64 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/list/app.py +97 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/update/app.py +149 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/create/app.py +67 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/delete/app.py +65 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/get/app.py +64 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/list/app.py +102 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/update/app.py +127 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/create/app.py +94 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/delete/app.py +66 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/get/app.py +67 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/list/app.py +95 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/update/app.py +156 -0
- geek_cafe_saas_sdk/domains/messaging/models/__init__.py +13 -0
- geek_cafe_saas_sdk/domains/messaging/models/chat_channel.py +337 -0
- geek_cafe_saas_sdk/domains/messaging/models/chat_channel_member.py +180 -0
- geek_cafe_saas_sdk/domains/messaging/models/chat_message.py +426 -0
- geek_cafe_saas_sdk/domains/messaging/models/contact_thread.py +392 -0
- geek_cafe_saas_sdk/domains/messaging/services/__init__.py +11 -0
- geek_cafe_saas_sdk/domains/messaging/services/chat_channel_service.py +700 -0
- geek_cafe_saas_sdk/domains/messaging/services/chat_message_service.py +491 -0
- geek_cafe_saas_sdk/domains/messaging/services/contact_thread_service.py +497 -0
- geek_cafe_saas_sdk/domains/tenancy/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/activate/app.py +52 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/active/app.py +37 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/cancel/app.py +55 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/list/app.py +44 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/record_payment/app.py +56 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/me/app.py +37 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/signup/app.py +61 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/tenancy/models/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/tenancy/models/subscription.py +440 -0
- geek_cafe_saas_sdk/domains/tenancy/models/tenant.py +258 -0
- geek_cafe_saas_sdk/domains/tenancy/services/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/tenancy/services/subscription_service.py +557 -0
- geek_cafe_saas_sdk/domains/tenancy/services/tenant_service.py +575 -0
- geek_cafe_saas_sdk/domains/voting/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/voting/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/create/app.py +128 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/delete/app.py +41 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/list/app.py +38 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/summerize/README.md +3 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/voting/models/__init__.py +9 -0
- geek_cafe_saas_sdk/domains/voting/models/vote.py +231 -0
- geek_cafe_saas_sdk/domains/voting/models/vote_summary.py +193 -0
- geek_cafe_saas_sdk/domains/voting/services/__init__.py +11 -0
- geek_cafe_saas_sdk/domains/voting/services/vote_service.py +264 -0
- geek_cafe_saas_sdk/domains/voting/services/vote_summary_service.py +198 -0
- geek_cafe_saas_sdk/domains/voting/services/vote_tally_service.py +533 -0
- geek_cafe_saas_sdk/lambda_handlers/README.md +404 -0
- geek_cafe_saas_sdk/lambda_handlers/__init__.py +67 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/__init__.py +25 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/api_key_handler.py +129 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/authorized_secure_handler.py +218 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/base_handler.py +185 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/handler_factory.py +256 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/public_handler.py +53 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/secure_handler.py +89 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/service_pool.py +94 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/create/app.py +79 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/delete/app.py +76 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/get/app.py +74 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/list/app.py +75 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/move/app.py +79 -0
- geek_cafe_saas_sdk/lambda_handlers/files/delete/app.py +121 -0
- geek_cafe_saas_sdk/lambda_handlers/files/download/app.py +187 -0
- geek_cafe_saas_sdk/lambda_handlers/files/get/app.py +127 -0
- geek_cafe_saas_sdk/lambda_handlers/files/list/app.py +108 -0
- geek_cafe_saas_sdk/lambda_handlers/files/share/app.py +83 -0
- geek_cafe_saas_sdk/lambda_handlers/files/shares/list/app.py +84 -0
- geek_cafe_saas_sdk/lambda_handlers/files/shares/revoke/app.py +76 -0
- geek_cafe_saas_sdk/lambda_handlers/files/update/app.py +143 -0
- geek_cafe_saas_sdk/lambda_handlers/files/upload/app.py +151 -0
- geek_cafe_saas_sdk/middleware/__init__.py +36 -0
- geek_cafe_saas_sdk/middleware/auth.py +85 -0
- geek_cafe_saas_sdk/middleware/authorization.py +523 -0
- geek_cafe_saas_sdk/middleware/cors.py +63 -0
- geek_cafe_saas_sdk/middleware/error_handling.py +114 -0
- geek_cafe_saas_sdk/middleware/validation.py +80 -0
- geek_cafe_saas_sdk/models/__init__.py +20 -0
- geek_cafe_saas_sdk/models/base_model.py +233 -0
- geek_cafe_saas_sdk/services/__init__.py +18 -0
- geek_cafe_saas_sdk/services/database_service.py +441 -0
- geek_cafe_saas_sdk/utilities/__init__.py +88 -0
- geek_cafe_saas_sdk/utilities/cognito_utility.py +568 -0
- geek_cafe_saas_sdk/utilities/custom_exceptions.py +183 -0
- geek_cafe_saas_sdk/utilities/datetime_utility.py +410 -0
- geek_cafe_saas_sdk/utilities/dictionary_utility.py +78 -0
- geek_cafe_saas_sdk/utilities/dynamodb_utils.py +151 -0
- geek_cafe_saas_sdk/utilities/environment_loader.py +149 -0
- geek_cafe_saas_sdk/utilities/environment_variables.py +228 -0
- geek_cafe_saas_sdk/utilities/http_body_parameters.py +44 -0
- geek_cafe_saas_sdk/utilities/http_path_parameters.py +60 -0
- geek_cafe_saas_sdk/utilities/http_status_code.py +63 -0
- geek_cafe_saas_sdk/utilities/jwt_utility.py +234 -0
- geek_cafe_saas_sdk/utilities/lambda_event_utility.py +776 -0
- geek_cafe_saas_sdk/utilities/logging_utility.py +64 -0
- geek_cafe_saas_sdk/utilities/message_query_helper.py +340 -0
- geek_cafe_saas_sdk/utilities/response.py +209 -0
- geek_cafe_saas_sdk/utilities/string_functions.py +180 -0
- geek_cafe_saas_sdk-0.6.0.dist-info/METADATA +397 -0
- geek_cafe_saas_sdk-0.6.0.dist-info/RECORD +194 -0
- geek_cafe_saas_sdk-0.6.0.dist-info/WHEEL +4 -0
- 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)
|