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

Files changed (79) hide show
  1. geek_cafe_saas_sdk/__init__.py +1 -1
  2. geek_cafe_saas_sdk/domains/files/models/directory.py +42 -6
  3. geek_cafe_saas_sdk/domains/files/models/file.py +40 -4
  4. geek_cafe_saas_sdk/domains/files/models/file_share.py +33 -0
  5. geek_cafe_saas_sdk/domains/files/models/file_version.py +24 -0
  6. geek_cafe_saas_sdk/domains/files/services/directory_service.py +54 -135
  7. geek_cafe_saas_sdk/domains/files/services/file_share_service.py +37 -120
  8. geek_cafe_saas_sdk/domains/files/services/file_system_service.py +40 -102
  9. geek_cafe_saas_sdk/domains/files/services/file_version_service.py +44 -124
  10. geek_cafe_saas_sdk/domains/messaging/services/contact_thread_service.py +55 -7
  11. geek_cafe_saas_sdk/domains/notifications/__init__.py +18 -0
  12. geek_cafe_saas_sdk/domains/notifications/handlers/__init__.py +1 -0
  13. geek_cafe_saas_sdk/domains/notifications/handlers/create_webhook/app.py +73 -0
  14. geek_cafe_saas_sdk/domains/notifications/handlers/get/app.py +40 -0
  15. geek_cafe_saas_sdk/domains/notifications/handlers/get_preferences/app.py +34 -0
  16. geek_cafe_saas_sdk/domains/notifications/handlers/list/app.py +43 -0
  17. geek_cafe_saas_sdk/domains/notifications/handlers/list_webhooks/app.py +40 -0
  18. geek_cafe_saas_sdk/domains/notifications/handlers/mark_read/app.py +40 -0
  19. geek_cafe_saas_sdk/domains/notifications/handlers/send/app.py +83 -0
  20. geek_cafe_saas_sdk/domains/notifications/handlers/update_preferences/app.py +45 -0
  21. geek_cafe_saas_sdk/domains/notifications/models/__init__.py +16 -0
  22. geek_cafe_saas_sdk/domains/notifications/models/notification.py +717 -0
  23. geek_cafe_saas_sdk/domains/notifications/models/notification_preference.py +365 -0
  24. geek_cafe_saas_sdk/domains/notifications/models/webhook_subscription.py +339 -0
  25. geek_cafe_saas_sdk/domains/notifications/services/__init__.py +10 -0
  26. geek_cafe_saas_sdk/domains/notifications/services/notification_service.py +576 -0
  27. geek_cafe_saas_sdk/domains/payments/__init__.py +16 -0
  28. geek_cafe_saas_sdk/domains/payments/handlers/README.md +334 -0
  29. geek_cafe_saas_sdk/domains/payments/handlers/__init__.py +6 -0
  30. geek_cafe_saas_sdk/domains/payments/handlers/billing_accounts/create/app.py +105 -0
  31. geek_cafe_saas_sdk/domains/payments/handlers/billing_accounts/get/app.py +60 -0
  32. geek_cafe_saas_sdk/domains/payments/handlers/billing_accounts/update/app.py +97 -0
  33. geek_cafe_saas_sdk/domains/payments/handlers/payment_intents/create/app.py +97 -0
  34. geek_cafe_saas_sdk/domains/payments/handlers/payment_intents/get/app.py +60 -0
  35. geek_cafe_saas_sdk/domains/payments/handlers/payments/get/app.py +60 -0
  36. geek_cafe_saas_sdk/domains/payments/handlers/payments/list/app.py +68 -0
  37. geek_cafe_saas_sdk/domains/payments/handlers/payments/record/app.py +118 -0
  38. geek_cafe_saas_sdk/domains/payments/handlers/refunds/create/app.py +89 -0
  39. geek_cafe_saas_sdk/domains/payments/handlers/refunds/get/app.py +60 -0
  40. geek_cafe_saas_sdk/domains/payments/models/__init__.py +17 -0
  41. geek_cafe_saas_sdk/domains/payments/models/billing_account.py +521 -0
  42. geek_cafe_saas_sdk/domains/payments/models/payment.py +639 -0
  43. geek_cafe_saas_sdk/domains/payments/models/payment_intent_ref.py +539 -0
  44. geek_cafe_saas_sdk/domains/payments/models/refund.py +404 -0
  45. geek_cafe_saas_sdk/domains/payments/services/__init__.py +11 -0
  46. geek_cafe_saas_sdk/domains/payments/services/payment_service.py +405 -0
  47. geek_cafe_saas_sdk/domains/subscriptions/__init__.py +19 -0
  48. geek_cafe_saas_sdk/domains/subscriptions/handlers/README.md +408 -0
  49. geek_cafe_saas_sdk/domains/subscriptions/handlers/__init__.py +1 -0
  50. geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/create/app.py +81 -0
  51. geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/get/app.py +48 -0
  52. geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/list/app.py +54 -0
  53. geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/update/app.py +54 -0
  54. geek_cafe_saas_sdk/domains/subscriptions/handlers/discounts/create/app.py +83 -0
  55. geek_cafe_saas_sdk/domains/subscriptions/handlers/discounts/get/app.py +47 -0
  56. geek_cafe_saas_sdk/domains/subscriptions/handlers/discounts/validate/app.py +62 -0
  57. geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/create/app.py +82 -0
  58. geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/get/app.py +48 -0
  59. geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/list/app.py +66 -0
  60. geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/update/app.py +54 -0
  61. geek_cafe_saas_sdk/domains/subscriptions/handlers/usage/aggregate/app.py +72 -0
  62. geek_cafe_saas_sdk/domains/subscriptions/handlers/usage/record/app.py +89 -0
  63. geek_cafe_saas_sdk/domains/subscriptions/models/__init__.py +13 -0
  64. geek_cafe_saas_sdk/domains/subscriptions/models/addon.py +604 -0
  65. geek_cafe_saas_sdk/domains/subscriptions/models/discount.py +492 -0
  66. geek_cafe_saas_sdk/domains/subscriptions/models/plan.py +569 -0
  67. geek_cafe_saas_sdk/domains/subscriptions/models/usage_record.py +300 -0
  68. geek_cafe_saas_sdk/domains/subscriptions/services/__init__.py +10 -0
  69. geek_cafe_saas_sdk/domains/subscriptions/services/subscription_manager_service.py +694 -0
  70. geek_cafe_saas_sdk/domains/tenancy/models/subscription.py +123 -1
  71. geek_cafe_saas_sdk/domains/tenancy/services/subscription_service.py +213 -0
  72. geek_cafe_saas_sdk/lambda_handlers/_base/base_handler.py +7 -0
  73. geek_cafe_saas_sdk/services/database_service.py +10 -6
  74. geek_cafe_saas_sdk/utilities/environment_variables.py +16 -0
  75. geek_cafe_saas_sdk/utilities/logging_utility.py +77 -0
  76. {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/METADATA +1 -1
  77. {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/RECORD +79 -20
  78. {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/WHEEL +0 -0
  79. {geek_cafe_saas_sdk-0.7.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")