geek-cafe-saas-sdk 0.6.0__py3-none-any.whl → 0.7.1__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 +2 -2
- geek_cafe_saas_sdk/domains/files/handlers/README.md +446 -0
- geek_cafe_saas_sdk/domains/files/handlers/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/files/handlers/files/create/app.py +121 -0
- geek_cafe_saas_sdk/domains/files/handlers/files/download/app.py +80 -0
- geek_cafe_saas_sdk/domains/files/handlers/files/get/app.py +62 -0
- geek_cafe_saas_sdk/domains/files/handlers/files/list/app.py +72 -0
- geek_cafe_saas_sdk/domains/files/handlers/lineage/create_derived/app.py +99 -0
- geek_cafe_saas_sdk/domains/files/handlers/lineage/create_main/app.py +104 -0
- geek_cafe_saas_sdk/domains/files/handlers/lineage/download_bundle/app.py +99 -0
- geek_cafe_saas_sdk/domains/files/handlers/lineage/get_lineage/app.py +68 -0
- geek_cafe_saas_sdk/domains/files/handlers/lineage/prepare_bundle/app.py +76 -0
- geek_cafe_saas_sdk/domains/files/models/__init__.py +17 -0
- geek_cafe_saas_sdk/domains/files/models/directory.py +42 -6
- geek_cafe_saas_sdk/domains/files/models/file.py +158 -16
- geek_cafe_saas_sdk/domains/files/models/file_share.py +33 -0
- geek_cafe_saas_sdk/domains/files/models/file_version.py +24 -0
- geek_cafe_saas_sdk/domains/files/services/__init__.py +21 -0
- geek_cafe_saas_sdk/domains/files/services/directory_service.py +54 -135
- geek_cafe_saas_sdk/domains/files/services/file_lineage_service.py +487 -0
- geek_cafe_saas_sdk/domains/files/services/file_share_service.py +37 -120
- geek_cafe_saas_sdk/domains/files/services/file_system_service.py +67 -103
- geek_cafe_saas_sdk/domains/files/services/file_version_service.py +44 -124
- geek_cafe_saas_sdk/domains/messaging/services/contact_thread_service.py +55 -7
- geek_cafe_saas_sdk/domains/notifications/__init__.py +18 -0
- geek_cafe_saas_sdk/domains/notifications/handlers/__init__.py +1 -0
- geek_cafe_saas_sdk/domains/notifications/handlers/create_webhook/app.py +73 -0
- geek_cafe_saas_sdk/domains/notifications/handlers/get/app.py +40 -0
- geek_cafe_saas_sdk/domains/notifications/handlers/get_preferences/app.py +34 -0
- geek_cafe_saas_sdk/domains/notifications/handlers/list/app.py +43 -0
- geek_cafe_saas_sdk/domains/notifications/handlers/list_webhooks/app.py +40 -0
- geek_cafe_saas_sdk/domains/notifications/handlers/mark_read/app.py +40 -0
- geek_cafe_saas_sdk/domains/notifications/handlers/send/app.py +83 -0
- geek_cafe_saas_sdk/domains/notifications/handlers/update_preferences/app.py +45 -0
- geek_cafe_saas_sdk/domains/notifications/models/__init__.py +16 -0
- geek_cafe_saas_sdk/domains/notifications/models/notification.py +717 -0
- geek_cafe_saas_sdk/domains/notifications/models/notification_preference.py +365 -0
- geek_cafe_saas_sdk/domains/notifications/models/webhook_subscription.py +339 -0
- geek_cafe_saas_sdk/domains/notifications/services/__init__.py +10 -0
- geek_cafe_saas_sdk/domains/notifications/services/notification_service.py +576 -0
- geek_cafe_saas_sdk/domains/payments/__init__.py +16 -0
- geek_cafe_saas_sdk/domains/payments/handlers/README.md +334 -0
- geek_cafe_saas_sdk/domains/payments/handlers/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/payments/handlers/billing_accounts/create/app.py +105 -0
- geek_cafe_saas_sdk/domains/payments/handlers/billing_accounts/get/app.py +60 -0
- geek_cafe_saas_sdk/domains/payments/handlers/billing_accounts/update/app.py +97 -0
- geek_cafe_saas_sdk/domains/payments/handlers/payment_intents/create/app.py +97 -0
- geek_cafe_saas_sdk/domains/payments/handlers/payment_intents/get/app.py +60 -0
- geek_cafe_saas_sdk/domains/payments/handlers/payments/get/app.py +60 -0
- geek_cafe_saas_sdk/domains/payments/handlers/payments/list/app.py +68 -0
- geek_cafe_saas_sdk/domains/payments/handlers/payments/record/app.py +118 -0
- geek_cafe_saas_sdk/domains/payments/handlers/refunds/create/app.py +89 -0
- geek_cafe_saas_sdk/domains/payments/handlers/refunds/get/app.py +60 -0
- geek_cafe_saas_sdk/domains/payments/models/__init__.py +17 -0
- geek_cafe_saas_sdk/domains/payments/models/billing_account.py +521 -0
- geek_cafe_saas_sdk/domains/payments/models/payment.py +639 -0
- geek_cafe_saas_sdk/domains/payments/models/payment_intent_ref.py +539 -0
- geek_cafe_saas_sdk/domains/payments/models/refund.py +404 -0
- geek_cafe_saas_sdk/domains/payments/services/__init__.py +11 -0
- geek_cafe_saas_sdk/domains/payments/services/payment_service.py +405 -0
- geek_cafe_saas_sdk/domains/subscriptions/__init__.py +19 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/README.md +408 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/__init__.py +1 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/create/app.py +81 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/get/app.py +48 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/list/app.py +54 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/update/app.py +54 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/discounts/create/app.py +83 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/discounts/get/app.py +47 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/discounts/validate/app.py +62 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/create/app.py +82 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/get/app.py +48 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/list/app.py +66 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/update/app.py +54 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/usage/aggregate/app.py +72 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/usage/record/app.py +89 -0
- geek_cafe_saas_sdk/domains/subscriptions/models/__init__.py +13 -0
- geek_cafe_saas_sdk/domains/subscriptions/models/addon.py +604 -0
- geek_cafe_saas_sdk/domains/subscriptions/models/discount.py +492 -0
- geek_cafe_saas_sdk/domains/subscriptions/models/plan.py +569 -0
- geek_cafe_saas_sdk/domains/subscriptions/models/usage_record.py +300 -0
- geek_cafe_saas_sdk/domains/subscriptions/services/__init__.py +10 -0
- geek_cafe_saas_sdk/domains/subscriptions/services/subscription_manager_service.py +694 -0
- geek_cafe_saas_sdk/domains/tenancy/models/subscription.py +123 -1
- geek_cafe_saas_sdk/domains/tenancy/services/subscription_service.py +213 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/base_handler.py +7 -0
- geek_cafe_saas_sdk/services/database_service.py +10 -6
- geek_cafe_saas_sdk/utilities/cognito_utility.py +16 -26
- geek_cafe_saas_sdk/utilities/environment_variables.py +16 -0
- geek_cafe_saas_sdk/utilities/logging_utility.py +77 -0
- {geek_cafe_saas_sdk-0.6.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/METADATA +11 -11
- {geek_cafe_saas_sdk-0.6.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/RECORD +94 -23
- {geek_cafe_saas_sdk-0.6.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/WHEEL +0 -0
- {geek_cafe_saas_sdk-0.6.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SubscriptionManagerService for managing subscription plans, addons, usage, and discounts.
|
|
3
|
+
|
|
4
|
+
Geek Cafe, LLC
|
|
5
|
+
MIT License. See Project Root for the license information.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Dict, Any, Optional, List
|
|
9
|
+
import datetime as dt
|
|
10
|
+
from boto3.dynamodb.conditions import Key
|
|
11
|
+
from boto3_assist.dynamodb.dynamodb import DynamoDB
|
|
12
|
+
from geek_cafe_saas_sdk.services.database_service import DatabaseService
|
|
13
|
+
from geek_cafe_saas_sdk.core.service_result import ServiceResult
|
|
14
|
+
from geek_cafe_saas_sdk.core.service_errors import ValidationError, NotFoundError
|
|
15
|
+
from geek_cafe_saas_sdk.core.error_codes import ErrorCode
|
|
16
|
+
from geek_cafe_saas_sdk.domains.subscriptions.models import Plan, Addon, UsageRecord, Discount
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SubscriptionManagerService(DatabaseService[Plan]):
|
|
20
|
+
"""
|
|
21
|
+
Service for managing subscription catalog: plans, addons, usage, and discounts.
|
|
22
|
+
|
|
23
|
+
This service manages the platform-wide subscription configuration:
|
|
24
|
+
- Plans: Tier definitions (Free, Pro, Enterprise)
|
|
25
|
+
- Addons: Billable modules (Chat, Voting, Storage)
|
|
26
|
+
- Usage Records: Metered billing events
|
|
27
|
+
- Discounts: Promo codes and credits
|
|
28
|
+
|
|
29
|
+
Note: This is separate from SubscriptionService which manages
|
|
30
|
+
per-tenant subscription instances.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, *, dynamodb: Optional[DynamoDB] = None, table_name: Optional[str] = None):
|
|
34
|
+
super().__init__(dynamodb=dynamodb, table_name=table_name)
|
|
35
|
+
|
|
36
|
+
# ========================================================================
|
|
37
|
+
# Abstract Method Implementations (DatabaseService)
|
|
38
|
+
# ========================================================================
|
|
39
|
+
|
|
40
|
+
def create(self, **kwargs) -> ServiceResult[Plan]:
|
|
41
|
+
"""Create a plan (delegates to create_plan)."""
|
|
42
|
+
return self.create_plan(**kwargs)
|
|
43
|
+
|
|
44
|
+
def get_by_id(self, id: str, **kwargs) -> ServiceResult[Plan]:
|
|
45
|
+
"""Get a plan by ID."""
|
|
46
|
+
return self.get_plan(plan_id=id)
|
|
47
|
+
|
|
48
|
+
def update(self, id: str, updates: Dict[str, Any], **kwargs) -> ServiceResult[Plan]:
|
|
49
|
+
"""Update a plan."""
|
|
50
|
+
return self.update_plan(plan_id=id, updates=updates)
|
|
51
|
+
|
|
52
|
+
def delete(self, id: str, **kwargs) -> ServiceResult[Plan]:
|
|
53
|
+
"""Archive a plan (soft delete)."""
|
|
54
|
+
return self.archive_plan(plan_id=id)
|
|
55
|
+
|
|
56
|
+
# ========================================================================
|
|
57
|
+
# Plan Management
|
|
58
|
+
# ========================================================================
|
|
59
|
+
|
|
60
|
+
def create_plan(
|
|
61
|
+
self,
|
|
62
|
+
plan_code: str,
|
|
63
|
+
plan_name: str,
|
|
64
|
+
price_monthly_cents: int,
|
|
65
|
+
**kwargs
|
|
66
|
+
) -> ServiceResult[Plan]:
|
|
67
|
+
"""
|
|
68
|
+
Create a new subscription plan.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
plan_code: Unique plan identifier
|
|
72
|
+
plan_name: Display name
|
|
73
|
+
price_monthly_cents: Monthly price in cents
|
|
74
|
+
**kwargs: Additional plan fields
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
ServiceResult with Plan
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
plan = Plan()
|
|
81
|
+
|
|
82
|
+
# Set optional fields
|
|
83
|
+
plan = plan.map(kwargs)
|
|
84
|
+
|
|
85
|
+
# Set known fields
|
|
86
|
+
plan.plan_code = plan_code
|
|
87
|
+
plan.plan_name = plan_name
|
|
88
|
+
plan.price_monthly_cents = price_monthly_cents
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Validate
|
|
93
|
+
is_valid, errors = plan.validate()
|
|
94
|
+
if not is_valid:
|
|
95
|
+
return ServiceResult.error_result(
|
|
96
|
+
message=f"Validation failed: {', '.join(errors)}",
|
|
97
|
+
error_code=ErrorCode.VALIDATION_ERROR
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Save using helper method - automatically handles pk/sk from _setup_indexes()
|
|
101
|
+
plan.prep_for_save()
|
|
102
|
+
return self._save_model(plan)
|
|
103
|
+
|
|
104
|
+
except Exception as e:
|
|
105
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "create_plan")
|
|
106
|
+
|
|
107
|
+
def get_plan(self, plan_id: str) -> ServiceResult[Plan]:
|
|
108
|
+
"""Get a plan by ID."""
|
|
109
|
+
try:
|
|
110
|
+
plan = self._get_model_by_id(plan_id, Plan)
|
|
111
|
+
|
|
112
|
+
if not plan:
|
|
113
|
+
return ServiceResult.error_result(
|
|
114
|
+
message=f"Plan not found: {plan_id}",
|
|
115
|
+
error_code=ErrorCode.NOT_FOUND
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return ServiceResult.success_result(plan)
|
|
119
|
+
|
|
120
|
+
except Exception as e:
|
|
121
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "get_plan")
|
|
122
|
+
|
|
123
|
+
def get_plan_by_code(self, plan_code: str) -> ServiceResult[Plan]:
|
|
124
|
+
"""Get a plan by plan code."""
|
|
125
|
+
try:
|
|
126
|
+
# Query using GSI on plan_code
|
|
127
|
+
# This assumes plans are queryable by code
|
|
128
|
+
plans = self.list_plans(status="active")
|
|
129
|
+
|
|
130
|
+
if not plans.success:
|
|
131
|
+
return plans
|
|
132
|
+
|
|
133
|
+
for plan in plans.data:
|
|
134
|
+
if plan.plan_code == plan_code:
|
|
135
|
+
return ServiceResult.success_result(plan)
|
|
136
|
+
|
|
137
|
+
return ServiceResult.error_result(
|
|
138
|
+
message=f"Plan not found with code: {plan_code}",
|
|
139
|
+
error_code=ErrorCode.NOT_FOUND
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
except Exception as e:
|
|
143
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "get_plan_by_code")
|
|
144
|
+
|
|
145
|
+
def update_plan(self, plan_id: str, updates: Dict[str, Any]) -> ServiceResult[Plan]:
|
|
146
|
+
"""Update a plan."""
|
|
147
|
+
try:
|
|
148
|
+
# Get existing plan
|
|
149
|
+
result = self.get_plan(plan_id)
|
|
150
|
+
if not result.success:
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
plan = result.data
|
|
154
|
+
|
|
155
|
+
# Apply updates
|
|
156
|
+
for key, value in updates.items():
|
|
157
|
+
if hasattr(plan, key) and not key.startswith('_'):
|
|
158
|
+
setattr(plan, key, value)
|
|
159
|
+
|
|
160
|
+
# Validate
|
|
161
|
+
is_valid, errors = plan.validate()
|
|
162
|
+
if not is_valid:
|
|
163
|
+
return ServiceResult.error_result(
|
|
164
|
+
message=f"Validation failed: {', '.join(errors)}",
|
|
165
|
+
error_code=ErrorCode.VALIDATION_ERROR
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Increment version
|
|
169
|
+
plan.version += 1
|
|
170
|
+
plan.prep_for_save()
|
|
171
|
+
|
|
172
|
+
# Save using helper method
|
|
173
|
+
return self._save_model(plan)
|
|
174
|
+
|
|
175
|
+
except Exception as e:
|
|
176
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "update_plan")
|
|
177
|
+
|
|
178
|
+
def archive_plan(self, plan_id: str) -> ServiceResult[Plan]:
|
|
179
|
+
"""Archive a plan (soft delete)."""
|
|
180
|
+
return self.update_plan(plan_id, {"status": Plan.STATUS_ARCHIVED})
|
|
181
|
+
|
|
182
|
+
def list_plans(
|
|
183
|
+
self,
|
|
184
|
+
status: Optional[str] = None,
|
|
185
|
+
is_public: Optional[bool] = None,
|
|
186
|
+
limit: int = 50
|
|
187
|
+
) -> ServiceResult[List[Plan]]:
|
|
188
|
+
"""
|
|
189
|
+
List plans with optional filters.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
status: Filter by status
|
|
193
|
+
is_public: Filter by public visibility
|
|
194
|
+
limit: Maximum results
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
ServiceResult with list of Plans
|
|
198
|
+
"""
|
|
199
|
+
try:
|
|
200
|
+
# Create temp plan for query
|
|
201
|
+
temp_plan = Plan()
|
|
202
|
+
|
|
203
|
+
if status:
|
|
204
|
+
# Use GSI1 to query by status (already sorted by sort_order + name)
|
|
205
|
+
temp_plan.status = status
|
|
206
|
+
query_result = self._query_by_index(temp_plan, "gsi1", limit=limit, ascending=True)
|
|
207
|
+
else:
|
|
208
|
+
# Use GSI2 to get all plans (sorted by code)
|
|
209
|
+
query_result = self._query_by_index(temp_plan, "gsi2", limit=limit, ascending=True)
|
|
210
|
+
|
|
211
|
+
if not query_result.success:
|
|
212
|
+
return query_result
|
|
213
|
+
|
|
214
|
+
# Apply additional filters
|
|
215
|
+
plans = []
|
|
216
|
+
for plan in query_result.data:
|
|
217
|
+
if is_public is not None and plan.is_public != is_public:
|
|
218
|
+
continue
|
|
219
|
+
plans.append(plan)
|
|
220
|
+
|
|
221
|
+
return ServiceResult.success_result(plans)
|
|
222
|
+
|
|
223
|
+
except Exception as e:
|
|
224
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "list_plans")
|
|
225
|
+
|
|
226
|
+
# ========================================================================
|
|
227
|
+
# Addon Management
|
|
228
|
+
# ========================================================================
|
|
229
|
+
|
|
230
|
+
def create_addon(
|
|
231
|
+
self,
|
|
232
|
+
addon_code: str,
|
|
233
|
+
addon_name: str,
|
|
234
|
+
pricing_model: str,
|
|
235
|
+
**kwargs
|
|
236
|
+
) -> ServiceResult[Addon]:
|
|
237
|
+
"""
|
|
238
|
+
Create a new addon.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
addon_code: Unique addon identifier
|
|
242
|
+
addon_name: Display name
|
|
243
|
+
pricing_model: "fixed", "per_unit", or "tiered"
|
|
244
|
+
**kwargs: Additional addon fields
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
ServiceResult with Addon
|
|
248
|
+
"""
|
|
249
|
+
try:
|
|
250
|
+
addon = Addon()
|
|
251
|
+
# Set optional fields
|
|
252
|
+
addon = addon.map(kwargs)
|
|
253
|
+
# Set known fields
|
|
254
|
+
addon.addon_code = addon_code
|
|
255
|
+
addon.addon_name = addon_name
|
|
256
|
+
addon.pricing_model = pricing_model
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# Validate
|
|
261
|
+
is_valid, errors = addon.validate()
|
|
262
|
+
if not is_valid:
|
|
263
|
+
return ServiceResult.error_result(
|
|
264
|
+
message=f"Validation failed: {', '.join(errors)}",
|
|
265
|
+
error_code=ErrorCode.VALIDATION_ERROR
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# Save using helper method - automatically handles pk/sk from _setup_indexes()
|
|
269
|
+
addon.prep_for_save()
|
|
270
|
+
return self._save_model(addon)
|
|
271
|
+
|
|
272
|
+
except Exception as e:
|
|
273
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "create_addon")
|
|
274
|
+
|
|
275
|
+
def get_addon(self, addon_id: str) -> ServiceResult[Addon]:
|
|
276
|
+
"""Get an addon by ID."""
|
|
277
|
+
try:
|
|
278
|
+
addon = self._get_model_by_id(addon_id, Addon)
|
|
279
|
+
|
|
280
|
+
if not addon:
|
|
281
|
+
return ServiceResult.error_result(
|
|
282
|
+
message=f"Addon not found: {addon_id}",
|
|
283
|
+
error_code=ErrorCode.NOT_FOUND
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
return ServiceResult.success_result(addon)
|
|
287
|
+
|
|
288
|
+
except Exception as e:
|
|
289
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "get_addon")
|
|
290
|
+
|
|
291
|
+
def get_addon_by_code(self, addon_code: str) -> ServiceResult[Addon]:
|
|
292
|
+
"""Get an addon by addon code."""
|
|
293
|
+
try:
|
|
294
|
+
addons = self.list_addons(status="active")
|
|
295
|
+
|
|
296
|
+
if not addons.success:
|
|
297
|
+
return addons
|
|
298
|
+
|
|
299
|
+
for addon in addons.data:
|
|
300
|
+
if addon.addon_code == addon_code:
|
|
301
|
+
return ServiceResult.success_result(addon)
|
|
302
|
+
|
|
303
|
+
return ServiceResult.error_result(
|
|
304
|
+
message=f"Addon not found with code: {addon_code}",
|
|
305
|
+
error_code=ErrorCode.NOT_FOUND
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
except Exception as e:
|
|
309
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "get_addon_by_code")
|
|
310
|
+
|
|
311
|
+
def update_addon(self, addon_id: str, updates: Dict[str, Any]) -> ServiceResult[Addon]:
|
|
312
|
+
"""Update an addon."""
|
|
313
|
+
try:
|
|
314
|
+
result = self.get_addon(addon_id)
|
|
315
|
+
if not result.success:
|
|
316
|
+
return result
|
|
317
|
+
|
|
318
|
+
addon = result.data
|
|
319
|
+
|
|
320
|
+
for key, value in updates.items():
|
|
321
|
+
if hasattr(addon, key) and not key.startswith('_'):
|
|
322
|
+
setattr(addon, key, value)
|
|
323
|
+
|
|
324
|
+
is_valid, errors = addon.validate()
|
|
325
|
+
if not is_valid:
|
|
326
|
+
return ServiceResult.error_result(
|
|
327
|
+
message=f"Validation failed: {', '.join(errors)}",
|
|
328
|
+
error_code=ErrorCode.VALIDATION_ERROR
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
addon.version += 1
|
|
332
|
+
addon.prep_for_save()
|
|
333
|
+
|
|
334
|
+
return self._save_model(addon)
|
|
335
|
+
|
|
336
|
+
except Exception as e:
|
|
337
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "update_addon")
|
|
338
|
+
|
|
339
|
+
def list_addons(
|
|
340
|
+
self,
|
|
341
|
+
status: Optional[str] = None,
|
|
342
|
+
category: Optional[str] = None,
|
|
343
|
+
limit: int = 50
|
|
344
|
+
) -> ServiceResult[List[Addon]]:
|
|
345
|
+
"""List addons with optional filters."""
|
|
346
|
+
try:
|
|
347
|
+
# Create temp addon for query
|
|
348
|
+
temp_addon = Addon()
|
|
349
|
+
|
|
350
|
+
if status:
|
|
351
|
+
# Use GSI1 to query by status (already sorted by category + sort_order)
|
|
352
|
+
temp_addon.status = status
|
|
353
|
+
if category:
|
|
354
|
+
temp_addon.category = category
|
|
355
|
+
query_result = self._query_by_index(temp_addon, "gsi1", limit=limit, ascending=True)
|
|
356
|
+
else:
|
|
357
|
+
# Use GSI2 to get all addons
|
|
358
|
+
query_result = self._query_by_index(temp_addon, "gsi2", limit=limit, ascending=True)
|
|
359
|
+
|
|
360
|
+
if not query_result.success:
|
|
361
|
+
return query_result
|
|
362
|
+
|
|
363
|
+
# Apply additional filters if needed
|
|
364
|
+
addons = []
|
|
365
|
+
for addon in query_result.data:
|
|
366
|
+
if category and status is None and addon.category != category:
|
|
367
|
+
continue
|
|
368
|
+
addons.append(addon)
|
|
369
|
+
|
|
370
|
+
return ServiceResult.success_result(addons)
|
|
371
|
+
|
|
372
|
+
except Exception as e:
|
|
373
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "list_addons")
|
|
374
|
+
|
|
375
|
+
# ========================================================================
|
|
376
|
+
# Usage Record Management
|
|
377
|
+
# ========================================================================
|
|
378
|
+
|
|
379
|
+
def record_usage(
|
|
380
|
+
self,
|
|
381
|
+
tenant_id: str,
|
|
382
|
+
subscription_id: str,
|
|
383
|
+
addon_code: str,
|
|
384
|
+
meter_event_name: str,
|
|
385
|
+
quantity: float,
|
|
386
|
+
**kwargs
|
|
387
|
+
) -> ServiceResult[UsageRecord]:
|
|
388
|
+
"""
|
|
389
|
+
Record a usage event for metered billing.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
tenant_id: Tenant ID
|
|
393
|
+
subscription_id: Subscription ID
|
|
394
|
+
addon_code: Addon code
|
|
395
|
+
meter_event_name: Event name
|
|
396
|
+
quantity: Usage quantity
|
|
397
|
+
**kwargs: Additional fields
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
ServiceResult with UsageRecord
|
|
401
|
+
"""
|
|
402
|
+
try:
|
|
403
|
+
# Check for idempotency
|
|
404
|
+
idempotency_key = kwargs.get('idempotency_key')
|
|
405
|
+
if idempotency_key:
|
|
406
|
+
existing = self._get_usage_by_idempotency_key(idempotency_key)
|
|
407
|
+
if existing:
|
|
408
|
+
return ServiceResult.success_result(existing)
|
|
409
|
+
|
|
410
|
+
usage = UsageRecord()
|
|
411
|
+
# Set optional fields
|
|
412
|
+
usage = usage.map(kwargs)
|
|
413
|
+
|
|
414
|
+
# Set known fields
|
|
415
|
+
usage.tenant_id = tenant_id
|
|
416
|
+
usage.subscription_id = subscription_id
|
|
417
|
+
usage.addon_code = addon_code
|
|
418
|
+
usage.meter_event_name = meter_event_name
|
|
419
|
+
usage.quantity = quantity
|
|
420
|
+
usage.timestamp_utc_ts = dt.datetime.now(dt.UTC).timestamp()
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# Save
|
|
425
|
+
usage.prep_for_save()
|
|
426
|
+
return self._save_model(usage)
|
|
427
|
+
|
|
428
|
+
except Exception as e:
|
|
429
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "record_usage")
|
|
430
|
+
|
|
431
|
+
def _get_usage_by_idempotency_key(self, idempotency_key: str) -> Optional[UsageRecord]:
|
|
432
|
+
"""Check if usage record exists with idempotency key."""
|
|
433
|
+
try:
|
|
434
|
+
# This would require a GSI on idempotency_key
|
|
435
|
+
# For now, return None (no deduplication)
|
|
436
|
+
return None
|
|
437
|
+
except:
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
def get_usage_for_period(
|
|
441
|
+
self,
|
|
442
|
+
tenant_id: str,
|
|
443
|
+
subscription_id: str,
|
|
444
|
+
addon_code: str,
|
|
445
|
+
period_start: float,
|
|
446
|
+
period_end: float
|
|
447
|
+
) -> ServiceResult[List[UsageRecord]]:
|
|
448
|
+
"""
|
|
449
|
+
Get usage records for a billing period.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
tenant_id: Tenant ID
|
|
453
|
+
subscription_id: Subscription ID
|
|
454
|
+
addon_code: Addon code
|
|
455
|
+
period_start: Period start timestamp
|
|
456
|
+
period_end: Period end timestamp
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
ServiceResult with list of UsageRecords
|
|
460
|
+
"""
|
|
461
|
+
try:
|
|
462
|
+
# This would ideally use a GSI for efficient querying
|
|
463
|
+
# For now, scan with filters
|
|
464
|
+
result = self.dynamodb.scan(
|
|
465
|
+
table_name=self.table_name
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
records = []
|
|
469
|
+
for item in result.get('Items', []):
|
|
470
|
+
if item.get('pk', '').startswith('usage#'):
|
|
471
|
+
usage = UsageRecord()
|
|
472
|
+
usage.map(item)
|
|
473
|
+
|
|
474
|
+
if (usage.tenant_id == tenant_id and
|
|
475
|
+
usage.subscription_id == subscription_id and
|
|
476
|
+
usage.addon_code == addon_code and
|
|
477
|
+
period_start <= usage.timestamp_utc_ts <= period_end):
|
|
478
|
+
records.append(usage)
|
|
479
|
+
|
|
480
|
+
return ServiceResult.success_result(records)
|
|
481
|
+
|
|
482
|
+
except Exception as e:
|
|
483
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "get_usage_for_period")
|
|
484
|
+
|
|
485
|
+
def aggregate_usage(
|
|
486
|
+
self,
|
|
487
|
+
tenant_id: str,
|
|
488
|
+
subscription_id: str,
|
|
489
|
+
addon_code: str,
|
|
490
|
+
period_start: float,
|
|
491
|
+
period_end: float
|
|
492
|
+
) -> ServiceResult[float]:
|
|
493
|
+
"""Aggregate total usage for a period."""
|
|
494
|
+
try:
|
|
495
|
+
result = self.get_usage_for_period(
|
|
496
|
+
tenant_id, subscription_id, addon_code,
|
|
497
|
+
period_start, period_end
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
if not result.success:
|
|
501
|
+
return result
|
|
502
|
+
|
|
503
|
+
total = sum(record.quantity for record in result.data)
|
|
504
|
+
return ServiceResult.success_result(total)
|
|
505
|
+
|
|
506
|
+
except Exception as e:
|
|
507
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "aggregate_usage")
|
|
508
|
+
|
|
509
|
+
# ========================================================================
|
|
510
|
+
# Discount Management
|
|
511
|
+
# ========================================================================
|
|
512
|
+
|
|
513
|
+
def create_discount(
|
|
514
|
+
self,
|
|
515
|
+
discount_code: str,
|
|
516
|
+
discount_name: str,
|
|
517
|
+
discount_type: str,
|
|
518
|
+
**kwargs
|
|
519
|
+
) -> ServiceResult[Discount]:
|
|
520
|
+
"""
|
|
521
|
+
Create a new discount/promo code.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
discount_code: Unique code (e.g., "SUMMER25")
|
|
525
|
+
discount_name: Display name
|
|
526
|
+
discount_type: "percentage", "fixed", "credit", "trial_extension"
|
|
527
|
+
**kwargs: Additional fields
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
ServiceResult with Discount
|
|
531
|
+
"""
|
|
532
|
+
try:
|
|
533
|
+
discount = Discount()
|
|
534
|
+
# Set optional fields
|
|
535
|
+
discount = discount.map(kwargs)
|
|
536
|
+
|
|
537
|
+
# Set known fields
|
|
538
|
+
discount.discount_code = discount_code
|
|
539
|
+
discount.discount_name = discount_name
|
|
540
|
+
discount.discount_type = discount_type
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
is_valid, errors = discount.validate()
|
|
545
|
+
if not is_valid:
|
|
546
|
+
return ServiceResult.error_result(
|
|
547
|
+
message=f"Validation failed: {', '.join(errors)}",
|
|
548
|
+
error_code=ErrorCode.VALIDATION_ERROR
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
discount.prep_for_save()
|
|
552
|
+
return self._save_model(discount)
|
|
553
|
+
|
|
554
|
+
except Exception as e:
|
|
555
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "create_discount")
|
|
556
|
+
|
|
557
|
+
def get_discount(self, discount_id: str) -> ServiceResult[Discount]:
|
|
558
|
+
"""Get a discount by ID."""
|
|
559
|
+
try:
|
|
560
|
+
discount = self._get_model_by_id(discount_id, Discount)
|
|
561
|
+
|
|
562
|
+
if not discount:
|
|
563
|
+
return ServiceResult.error_result(
|
|
564
|
+
message=f"Discount not found: {discount_id}",
|
|
565
|
+
error_code=ErrorCode.NOT_FOUND
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
return ServiceResult.success_result(discount)
|
|
569
|
+
|
|
570
|
+
except Exception as e:
|
|
571
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "get_discount")
|
|
572
|
+
|
|
573
|
+
def get_discount_by_code(self, discount_code: str) -> ServiceResult[Discount]:
|
|
574
|
+
"""Get a discount by code."""
|
|
575
|
+
try:
|
|
576
|
+
discounts = self.list_discounts(status="active")
|
|
577
|
+
|
|
578
|
+
if not discounts.success:
|
|
579
|
+
return discounts
|
|
580
|
+
|
|
581
|
+
for discount in discounts.data:
|
|
582
|
+
if discount.discount_code == discount_code.upper():
|
|
583
|
+
return ServiceResult.success_result(discount)
|
|
584
|
+
|
|
585
|
+
return ServiceResult.error_result(
|
|
586
|
+
message=f"Discount not found with code: {discount_code}",
|
|
587
|
+
error_code=ErrorCode.NOT_FOUND
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
except Exception as e:
|
|
591
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "get_discount_by_code")
|
|
592
|
+
|
|
593
|
+
def validate_discount(
|
|
594
|
+
self,
|
|
595
|
+
discount_code: str,
|
|
596
|
+
plan_code: Optional[str] = None,
|
|
597
|
+
amount_cents: Optional[int] = None,
|
|
598
|
+
is_first_purchase: bool = False
|
|
599
|
+
) -> ServiceResult[Discount]:
|
|
600
|
+
"""
|
|
601
|
+
Validate that a discount can be applied.
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
discount_code: Discount code to validate
|
|
605
|
+
plan_code: Plan code (optional)
|
|
606
|
+
amount_cents: Purchase amount (optional)
|
|
607
|
+
is_first_purchase: Whether this is first purchase
|
|
608
|
+
|
|
609
|
+
Returns:
|
|
610
|
+
ServiceResult with Discount if valid
|
|
611
|
+
"""
|
|
612
|
+
try:
|
|
613
|
+
result = self.get_discount_by_code(discount_code)
|
|
614
|
+
if not result.success:
|
|
615
|
+
return result
|
|
616
|
+
|
|
617
|
+
discount = result.data
|
|
618
|
+
|
|
619
|
+
# Check if can be redeemed
|
|
620
|
+
if not discount.can_be_redeemed():
|
|
621
|
+
return ServiceResult.error_result(
|
|
622
|
+
message="Discount code is not currently valid",
|
|
623
|
+
error_code=ErrorCode.VALIDATION_ERROR
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
# Check plan restriction
|
|
627
|
+
if plan_code and not discount.applies_to_plan(plan_code):
|
|
628
|
+
return ServiceResult.error_result(
|
|
629
|
+
message=f"Discount does not apply to plan: {plan_code}",
|
|
630
|
+
error_code=ErrorCode.VALIDATION_ERROR
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
# Check minimum amount
|
|
634
|
+
if amount_cents and discount.minimum_amount_cents:
|
|
635
|
+
if amount_cents < discount.minimum_amount_cents:
|
|
636
|
+
min_dollars = discount.minimum_amount_cents / 100.0
|
|
637
|
+
return ServiceResult.error_result(
|
|
638
|
+
message=f"Minimum purchase amount is ${min_dollars:.2f}",
|
|
639
|
+
error_code=ErrorCode.VALIDATION_ERROR
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
# Check first-time restriction
|
|
643
|
+
if discount.first_time_transaction and not is_first_purchase:
|
|
644
|
+
return ServiceResult.error_result(
|
|
645
|
+
message="Discount only valid for first-time purchases",
|
|
646
|
+
error_code=ErrorCode.VALIDATION_ERROR
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
return ServiceResult.success_result(discount)
|
|
650
|
+
|
|
651
|
+
except Exception as e:
|
|
652
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "validate_discount")
|
|
653
|
+
|
|
654
|
+
def redeem_discount(self, discount_id: str) -> ServiceResult[Discount]:
|
|
655
|
+
"""Increment redemption count for a discount."""
|
|
656
|
+
try:
|
|
657
|
+
result = self.get_discount(discount_id)
|
|
658
|
+
if not result.success:
|
|
659
|
+
return result
|
|
660
|
+
|
|
661
|
+
discount = result.data
|
|
662
|
+
discount.increment_redemption_count()
|
|
663
|
+
|
|
664
|
+
discount.prep_for_save()
|
|
665
|
+
return self._save_model(discount)
|
|
666
|
+
|
|
667
|
+
except Exception as e:
|
|
668
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "redeem_discount")
|
|
669
|
+
|
|
670
|
+
def list_discounts(
|
|
671
|
+
self,
|
|
672
|
+
status: Optional[str] = None,
|
|
673
|
+
limit: int = 50
|
|
674
|
+
) -> ServiceResult[List[Discount]]:
|
|
675
|
+
"""List discounts with optional filters."""
|
|
676
|
+
try:
|
|
677
|
+
# Create temp discount for query
|
|
678
|
+
temp_discount = Discount()
|
|
679
|
+
|
|
680
|
+
if status:
|
|
681
|
+
# Use GSI1 to query by status
|
|
682
|
+
temp_discount.status = status
|
|
683
|
+
query_result = self._query_by_index(temp_discount, "gsi1", limit=limit, ascending=False)
|
|
684
|
+
else:
|
|
685
|
+
# Use GSI2 to get all discounts
|
|
686
|
+
query_result = self._query_by_index(temp_discount, "gsi2", limit=limit, ascending=True)
|
|
687
|
+
|
|
688
|
+
if not query_result.success:
|
|
689
|
+
return query_result
|
|
690
|
+
|
|
691
|
+
return ServiceResult.success_result(query_result.data)
|
|
692
|
+
|
|
693
|
+
except Exception as e:
|
|
694
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "list_discounts")
|