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.
- geek_cafe_saas_sdk/__init__.py +1 -1
- geek_cafe_saas_sdk/domains/files/models/directory.py +42 -6
- geek_cafe_saas_sdk/domains/files/models/file.py +40 -4
- 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/directory_service.py +54 -135
- geek_cafe_saas_sdk/domains/files/services/file_share_service.py +37 -120
- geek_cafe_saas_sdk/domains/files/services/file_system_service.py +40 -102
- 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/environment_variables.py +16 -0
- geek_cafe_saas_sdk/utilities/logging_utility.py +77 -0
- {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/METADATA +1 -1
- {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/RECORD +79 -20
- {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/WHEEL +0 -0
- {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -45,11 +45,21 @@ class Subscription(BaseModel):
|
|
|
45
45
|
# Subscription status
|
|
46
46
|
self._status: str = "trial" # trial|active|past_due|canceled|expired
|
|
47
47
|
|
|
48
|
-
# Plan details
|
|
48
|
+
# Plan details (NEW: References platform-wide Plan model)
|
|
49
|
+
self._plan_id: str | None = None # Reference to subscriptions.Plan.id
|
|
49
50
|
self._plan_code: str | None = None # "free"|"basic"|"pro"|"enterprise"
|
|
50
51
|
self._plan_name: str | None = None # Display name
|
|
51
52
|
self._seat_count: int = 1 # Number of users/seats
|
|
52
53
|
|
|
54
|
+
# Addons (NEW: Support for add-on modules)
|
|
55
|
+
self._active_addons: list[str] = [] # List of active addon codes
|
|
56
|
+
self._addon_metadata: dict[str, dict] = {} # Per-addon settings
|
|
57
|
+
|
|
58
|
+
# Discounts (NEW: Promotional discounts)
|
|
59
|
+
self._discount_id: str | None = None # Reference to subscriptions.Discount.id
|
|
60
|
+
self._discount_code: str | None = None # Promo code used
|
|
61
|
+
self._discount_amount_cents: int = 0 # Discount applied per period
|
|
62
|
+
|
|
53
63
|
# Pricing
|
|
54
64
|
self._price_cents: int = 0 # Price in cents (e.g., 2999 = $29.99)
|
|
55
65
|
self._currency: str = "USD"
|
|
@@ -166,6 +176,16 @@ class Subscription(BaseModel):
|
|
|
166
176
|
raise ValueError(f"Invalid status: {value}. Must be one of {valid_statuses}")
|
|
167
177
|
self._status = value
|
|
168
178
|
|
|
179
|
+
# Plan ID (NEW)
|
|
180
|
+
@property
|
|
181
|
+
def plan_id(self) -> str | None:
|
|
182
|
+
"""Plan ID reference."""
|
|
183
|
+
return self._plan_id
|
|
184
|
+
|
|
185
|
+
@plan_id.setter
|
|
186
|
+
def plan_id(self, value: str | None):
|
|
187
|
+
self._plan_id = value
|
|
188
|
+
|
|
169
189
|
# Plan Code
|
|
170
190
|
@property
|
|
171
191
|
def plan_code(self) -> str | None:
|
|
@@ -372,6 +392,58 @@ class Subscription(BaseModel):
|
|
|
372
392
|
def notes(self, value: str | None):
|
|
373
393
|
self._notes = value
|
|
374
394
|
|
|
395
|
+
# Active Addons (NEW)
|
|
396
|
+
@property
|
|
397
|
+
def active_addons(self) -> list[str]:
|
|
398
|
+
"""List of active addon codes."""
|
|
399
|
+
return self._active_addons
|
|
400
|
+
|
|
401
|
+
@active_addons.setter
|
|
402
|
+
def active_addons(self, value: list[str]):
|
|
403
|
+
self._active_addons = value if value else []
|
|
404
|
+
|
|
405
|
+
# Addon Metadata (NEW)
|
|
406
|
+
@property
|
|
407
|
+
def addon_metadata(self) -> dict[str, dict]:
|
|
408
|
+
"""Per-addon metadata."""
|
|
409
|
+
return self._addon_metadata
|
|
410
|
+
|
|
411
|
+
@addon_metadata.setter
|
|
412
|
+
def addon_metadata(self, value: dict[str, dict]):
|
|
413
|
+
self._addon_metadata = value if value else {}
|
|
414
|
+
|
|
415
|
+
# Discount ID (NEW)
|
|
416
|
+
@property
|
|
417
|
+
def discount_id(self) -> str | None:
|
|
418
|
+
"""Discount ID reference."""
|
|
419
|
+
return self._discount_id
|
|
420
|
+
|
|
421
|
+
@discount_id.setter
|
|
422
|
+
def discount_id(self, value: str | None):
|
|
423
|
+
self._discount_id = value
|
|
424
|
+
|
|
425
|
+
# Discount Code (NEW)
|
|
426
|
+
@property
|
|
427
|
+
def discount_code(self) -> str | None:
|
|
428
|
+
"""Promo code used."""
|
|
429
|
+
return self._discount_code
|
|
430
|
+
|
|
431
|
+
@discount_code.setter
|
|
432
|
+
def discount_code(self, value: str | None):
|
|
433
|
+
self._discount_code = value
|
|
434
|
+
|
|
435
|
+
# Discount Amount Cents (NEW)
|
|
436
|
+
@property
|
|
437
|
+
def discount_amount_cents(self) -> int:
|
|
438
|
+
"""Discount amount per period in cents."""
|
|
439
|
+
return self._discount_amount_cents
|
|
440
|
+
|
|
441
|
+
@discount_amount_cents.setter
|
|
442
|
+
def discount_amount_cents(self, value: int):
|
|
443
|
+
if value < 0:
|
|
444
|
+
raise ValueError("discount_amount_cents cannot be negative")
|
|
445
|
+
self._discount_amount_cents = value
|
|
446
|
+
|
|
375
447
|
# Helper Methods
|
|
376
448
|
|
|
377
449
|
def is_active(self) -> bool:
|
|
@@ -438,3 +510,53 @@ class Subscription(BaseModel):
|
|
|
438
510
|
"""Get formatted price for display."""
|
|
439
511
|
dollars = self._price_cents / 100
|
|
440
512
|
return f"${dollars:.2f} {self._currency}"
|
|
513
|
+
|
|
514
|
+
# NEW Helper Methods for Addons and Discounts
|
|
515
|
+
|
|
516
|
+
def has_addon(self, addon_code: str) -> bool:
|
|
517
|
+
"""Check if addon is active on subscription."""
|
|
518
|
+
return addon_code in self._active_addons
|
|
519
|
+
|
|
520
|
+
def add_addon(self, addon_code: str, metadata: dict | None = None):
|
|
521
|
+
"""Add an addon to subscription."""
|
|
522
|
+
if addon_code not in self._active_addons:
|
|
523
|
+
self._active_addons.append(addon_code)
|
|
524
|
+
|
|
525
|
+
if metadata:
|
|
526
|
+
self._addon_metadata[addon_code] = metadata
|
|
527
|
+
|
|
528
|
+
def remove_addon(self, addon_code: str):
|
|
529
|
+
"""Remove an addon from subscription."""
|
|
530
|
+
if addon_code in self._active_addons:
|
|
531
|
+
self._active_addons.remove(addon_code)
|
|
532
|
+
|
|
533
|
+
if addon_code in self._addon_metadata:
|
|
534
|
+
del self._addon_metadata[addon_code]
|
|
535
|
+
|
|
536
|
+
def get_addon_metadata(self, addon_code: str) -> dict | None:
|
|
537
|
+
"""Get metadata for specific addon."""
|
|
538
|
+
return self._addon_metadata.get(addon_code)
|
|
539
|
+
|
|
540
|
+
def set_addon_metadata(self, addon_code: str, metadata: dict):
|
|
541
|
+
"""Set metadata for specific addon."""
|
|
542
|
+
self._addon_metadata[addon_code] = metadata
|
|
543
|
+
|
|
544
|
+
def has_discount(self) -> bool:
|
|
545
|
+
"""Check if subscription has an active discount."""
|
|
546
|
+
return self._discount_id is not None
|
|
547
|
+
|
|
548
|
+
def apply_discount(self, discount_id: str, discount_code: str, discount_amount_cents: int):
|
|
549
|
+
"""Apply a discount to subscription."""
|
|
550
|
+
self._discount_id = discount_id
|
|
551
|
+
self._discount_code = discount_code
|
|
552
|
+
self._discount_amount_cents = discount_amount_cents
|
|
553
|
+
|
|
554
|
+
def remove_discount(self):
|
|
555
|
+
"""Remove discount from subscription."""
|
|
556
|
+
self._discount_id = None
|
|
557
|
+
self._discount_code = None
|
|
558
|
+
self._discount_amount_cents = 0
|
|
559
|
+
|
|
560
|
+
def get_final_price_cents(self) -> int:
|
|
561
|
+
"""Get final price after discount."""
|
|
562
|
+
return max(0, self._price_cents - self._discount_amount_cents)
|
|
@@ -555,3 +555,216 @@ class SubscriptionService(DatabaseService[Subscription]):
|
|
|
555
555
|
except Exception as e:
|
|
556
556
|
return self._handle_service_exception(e, 'delete_subscription',
|
|
557
557
|
resource_id=resource_id, tenant_id=tenant_id)
|
|
558
|
+
|
|
559
|
+
# ========================================================================
|
|
560
|
+
# NEW: Plan and Addon Integration Methods
|
|
561
|
+
# ========================================================================
|
|
562
|
+
|
|
563
|
+
def create_from_plan(
|
|
564
|
+
self,
|
|
565
|
+
tenant_id: str,
|
|
566
|
+
user_id: str,
|
|
567
|
+
plan_id: str,
|
|
568
|
+
plan_code: str,
|
|
569
|
+
plan_name: str,
|
|
570
|
+
price_cents: int,
|
|
571
|
+
seat_count: int = 1,
|
|
572
|
+
billing_interval: str = "month",
|
|
573
|
+
**kwargs
|
|
574
|
+
) -> ServiceResult[Subscription]:
|
|
575
|
+
"""
|
|
576
|
+
Create a subscription from a Plan definition.
|
|
577
|
+
|
|
578
|
+
This links the tenant subscription to a platform-wide Plan,
|
|
579
|
+
copying pricing and configuration at subscription time.
|
|
580
|
+
|
|
581
|
+
Args:
|
|
582
|
+
tenant_id: Tenant ID
|
|
583
|
+
user_id: User creating subscription
|
|
584
|
+
plan_id: Reference to subscriptions.Plan.id
|
|
585
|
+
plan_code: Plan code
|
|
586
|
+
plan_name: Plan name
|
|
587
|
+
price_cents: Price in cents
|
|
588
|
+
seat_count: Number of seats
|
|
589
|
+
billing_interval: "month" or "year"
|
|
590
|
+
**kwargs: Additional fields
|
|
591
|
+
|
|
592
|
+
Returns:
|
|
593
|
+
ServiceResult with Subscription
|
|
594
|
+
"""
|
|
595
|
+
payload = {
|
|
596
|
+
"plan_id": plan_id,
|
|
597
|
+
"plan_code": plan_code,
|
|
598
|
+
"plan_name": plan_name,
|
|
599
|
+
"price_cents": price_cents,
|
|
600
|
+
"seat_count": seat_count,
|
|
601
|
+
"billing_interval": billing_interval,
|
|
602
|
+
**kwargs
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return self.activate_subscription(tenant_id, user_id, payload)
|
|
606
|
+
|
|
607
|
+
def add_addon_to_subscription(
|
|
608
|
+
self,
|
|
609
|
+
subscription_id: str,
|
|
610
|
+
tenant_id: str,
|
|
611
|
+
user_id: str,
|
|
612
|
+
addon_code: str,
|
|
613
|
+
addon_metadata: Optional[Dict[str, Any]] = None
|
|
614
|
+
) -> ServiceResult[Subscription]:
|
|
615
|
+
"""
|
|
616
|
+
Add an addon to an existing subscription.
|
|
617
|
+
|
|
618
|
+
Args:
|
|
619
|
+
subscription_id: Subscription ID
|
|
620
|
+
tenant_id: Tenant ID
|
|
621
|
+
user_id: User performing action
|
|
622
|
+
addon_code: Addon code to add
|
|
623
|
+
addon_metadata: Optional addon-specific settings
|
|
624
|
+
|
|
625
|
+
Returns:
|
|
626
|
+
ServiceResult with updated Subscription
|
|
627
|
+
"""
|
|
628
|
+
try:
|
|
629
|
+
# Get subscription
|
|
630
|
+
result = self.get_by_id(subscription_id, tenant_id, user_id)
|
|
631
|
+
if not result.success:
|
|
632
|
+
return result
|
|
633
|
+
|
|
634
|
+
subscription = result.data
|
|
635
|
+
|
|
636
|
+
# Add addon
|
|
637
|
+
subscription.add_addon(addon_code, addon_metadata)
|
|
638
|
+
|
|
639
|
+
# Update
|
|
640
|
+
subscription.updated_by_id = user_id
|
|
641
|
+
subscription.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
|
|
642
|
+
subscription.version += 1
|
|
643
|
+
|
|
644
|
+
# Save
|
|
645
|
+
return self._save_model(subscription)
|
|
646
|
+
|
|
647
|
+
except Exception as e:
|
|
648
|
+
return self._handle_service_exception(
|
|
649
|
+
e, 'add_addon_to_subscription',
|
|
650
|
+
subscription_id=subscription_id, tenant_id=tenant_id
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
def remove_addon_from_subscription(
|
|
654
|
+
self,
|
|
655
|
+
subscription_id: str,
|
|
656
|
+
tenant_id: str,
|
|
657
|
+
user_id: str,
|
|
658
|
+
addon_code: str
|
|
659
|
+
) -> ServiceResult[Subscription]:
|
|
660
|
+
"""
|
|
661
|
+
Remove an addon from a subscription.
|
|
662
|
+
|
|
663
|
+
Args:
|
|
664
|
+
subscription_id: Subscription ID
|
|
665
|
+
tenant_id: Tenant ID
|
|
666
|
+
user_id: User performing action
|
|
667
|
+
addon_code: Addon code to remove
|
|
668
|
+
|
|
669
|
+
Returns:
|
|
670
|
+
ServiceResult with updated Subscription
|
|
671
|
+
"""
|
|
672
|
+
try:
|
|
673
|
+
result = self.get_by_id(subscription_id, tenant_id, user_id)
|
|
674
|
+
if not result.success:
|
|
675
|
+
return result
|
|
676
|
+
|
|
677
|
+
subscription = result.data
|
|
678
|
+
subscription.remove_addon(addon_code)
|
|
679
|
+
|
|
680
|
+
subscription.updated_by_id = user_id
|
|
681
|
+
subscription.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
|
|
682
|
+
subscription.version += 1
|
|
683
|
+
|
|
684
|
+
return self._save_model(subscription)
|
|
685
|
+
|
|
686
|
+
except Exception as e:
|
|
687
|
+
return self._handle_service_exception(
|
|
688
|
+
e, 'remove_addon_from_subscription',
|
|
689
|
+
subscription_id=subscription_id, tenant_id=tenant_id
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
def apply_discount_to_subscription(
|
|
693
|
+
self,
|
|
694
|
+
subscription_id: str,
|
|
695
|
+
tenant_id: str,
|
|
696
|
+
user_id: str,
|
|
697
|
+
discount_id: str,
|
|
698
|
+
discount_code: str,
|
|
699
|
+
discount_amount_cents: int
|
|
700
|
+
) -> ServiceResult[Subscription]:
|
|
701
|
+
"""
|
|
702
|
+
Apply a discount to a subscription.
|
|
703
|
+
|
|
704
|
+
Args:
|
|
705
|
+
subscription_id: Subscription ID
|
|
706
|
+
tenant_id: Tenant ID
|
|
707
|
+
user_id: User performing action
|
|
708
|
+
discount_id: Reference to subscriptions.Discount.id
|
|
709
|
+
discount_code: Discount code
|
|
710
|
+
discount_amount_cents: Discount amount per period
|
|
711
|
+
|
|
712
|
+
Returns:
|
|
713
|
+
ServiceResult with updated Subscription
|
|
714
|
+
"""
|
|
715
|
+
try:
|
|
716
|
+
result = self.get_by_id(subscription_id, tenant_id, user_id)
|
|
717
|
+
if not result.success:
|
|
718
|
+
return result
|
|
719
|
+
|
|
720
|
+
subscription = result.data
|
|
721
|
+
subscription.apply_discount(discount_id, discount_code, discount_amount_cents)
|
|
722
|
+
|
|
723
|
+
subscription.updated_by_id = user_id
|
|
724
|
+
subscription.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
|
|
725
|
+
subscription.version += 1
|
|
726
|
+
|
|
727
|
+
return self._save_model(subscription)
|
|
728
|
+
|
|
729
|
+
except Exception as e:
|
|
730
|
+
return self._handle_service_exception(
|
|
731
|
+
e, 'apply_discount_to_subscription',
|
|
732
|
+
subscription_id=subscription_id, tenant_id=tenant_id
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
def remove_discount_from_subscription(
|
|
736
|
+
self,
|
|
737
|
+
subscription_id: str,
|
|
738
|
+
tenant_id: str,
|
|
739
|
+
user_id: str
|
|
740
|
+
) -> ServiceResult[Subscription]:
|
|
741
|
+
"""
|
|
742
|
+
Remove discount from a subscription.
|
|
743
|
+
|
|
744
|
+
Args:
|
|
745
|
+
subscription_id: Subscription ID
|
|
746
|
+
tenant_id: Tenant ID
|
|
747
|
+
user_id: User performing action
|
|
748
|
+
|
|
749
|
+
Returns:
|
|
750
|
+
ServiceResult with updated Subscription
|
|
751
|
+
"""
|
|
752
|
+
try:
|
|
753
|
+
result = self.get_by_id(subscription_id, tenant_id, user_id)
|
|
754
|
+
if not result.success:
|
|
755
|
+
return result
|
|
756
|
+
|
|
757
|
+
subscription = result.data
|
|
758
|
+
subscription.remove_discount()
|
|
759
|
+
|
|
760
|
+
subscription.updated_by_id = user_id
|
|
761
|
+
subscription.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
|
|
762
|
+
subscription.version += 1
|
|
763
|
+
|
|
764
|
+
return self._save_model(subscription)
|
|
765
|
+
|
|
766
|
+
except Exception as e:
|
|
767
|
+
return self._handle_service_exception(
|
|
768
|
+
e, 'remove_discount_from_subscription',
|
|
769
|
+
subscription_id=subscription_id, tenant_id=tenant_id
|
|
770
|
+
)
|
|
@@ -14,6 +14,8 @@ from geek_cafe_saas_sdk.utilities.response import (
|
|
|
14
14
|
service_result_to_response,
|
|
15
15
|
)
|
|
16
16
|
from geek_cafe_saas_sdk.utilities.lambda_event_utility import LambdaEventUtility
|
|
17
|
+
from geek_cafe_saas_sdk.utilities.logging_utility import LoggingUtility
|
|
18
|
+
from geek_cafe_saas_sdk.utilities.environment_variables import EnvironmentVariables
|
|
17
19
|
from geek_cafe_saas_sdk.middleware.auth import extract_user_context
|
|
18
20
|
from .service_pool import ServicePool
|
|
19
21
|
|
|
@@ -94,6 +96,11 @@ class BaseLambdaHandler:
|
|
|
94
96
|
Lambda response dictionary
|
|
95
97
|
"""
|
|
96
98
|
try:
|
|
99
|
+
# Log event payload if enabled (sanitized for security)
|
|
100
|
+
if EnvironmentVariables.should_log_lambda_events():
|
|
101
|
+
sanitized_event = LoggingUtility.sanitize_event_for_logging(event)
|
|
102
|
+
logger.info("Lambda event received", extra={"event": sanitized_event})
|
|
103
|
+
|
|
97
104
|
# Unwrap message if needed (SQS, SNS, etc.)
|
|
98
105
|
if self.unwrap_message and "message" in event:
|
|
99
106
|
event = event["message"]
|
|
@@ -142,7 +142,7 @@ class DatabaseService(ABC, Generic[T]):
|
|
|
142
142
|
return None
|
|
143
143
|
|
|
144
144
|
def _get_model_by_id_with_tenant_check(
|
|
145
|
-
self, resource_id: str, model_class, tenant_id: str
|
|
145
|
+
self, resource_id: str, model_class, tenant_id: str, include_deleted: bool = True
|
|
146
146
|
) -> Optional[T]:
|
|
147
147
|
"""
|
|
148
148
|
Get model by ID with automatic tenant validation.
|
|
@@ -155,13 +155,16 @@ class DatabaseService(ABC, Generic[T]):
|
|
|
155
155
|
resource_id: The resource ID to fetch
|
|
156
156
|
model_class: The model class to instantiate
|
|
157
157
|
tenant_id: The tenant ID from JWT (authenticated user's tenant)
|
|
158
|
+
include_deleted: If True, returns deleted items. If False, returns None for deleted items.
|
|
159
|
+
Default is True since get-by-id operations typically need to verify deletion,
|
|
160
|
+
perform restores, or show audit history.
|
|
158
161
|
|
|
159
162
|
Returns:
|
|
160
163
|
The model if found and belongs to tenant, None otherwise
|
|
161
164
|
|
|
162
165
|
Security:
|
|
163
166
|
- Returns None for resources in different tenants (prevents enumeration)
|
|
164
|
-
-
|
|
167
|
+
- Optionally filters deleted resources based on include_deleted parameter
|
|
165
168
|
- Single source of truth: tenant_id from JWT only
|
|
166
169
|
"""
|
|
167
170
|
model = self._get_model_by_id(resource_id, model_class)
|
|
@@ -175,10 +178,11 @@ class DatabaseService(ABC, Generic[T]):
|
|
|
175
178
|
# from users in other tenants (prevent enumeration attacks)
|
|
176
179
|
return None
|
|
177
180
|
|
|
178
|
-
#
|
|
179
|
-
if
|
|
180
|
-
if model.is_deleted
|
|
181
|
-
|
|
181
|
+
# Optionally hide deleted resources
|
|
182
|
+
if not include_deleted:
|
|
183
|
+
if hasattr(model, 'is_deleted') and callable(model.is_deleted):
|
|
184
|
+
if model.is_deleted():
|
|
185
|
+
return None
|
|
182
186
|
|
|
183
187
|
return model
|
|
184
188
|
|
|
@@ -226,3 +226,19 @@ class EnvironmentVariables:
|
|
|
226
226
|
"""
|
|
227
227
|
env = EnvironmentVariables.get_environment_setting()
|
|
228
228
|
return env.lower().startswith("dev")
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def should_log_lambda_events() -> bool:
|
|
232
|
+
"""
|
|
233
|
+
Determine if Lambda event payloads should be logged.
|
|
234
|
+
|
|
235
|
+
Set LOG_LAMBDA_EVENTS=true to enable event logging for debugging.
|
|
236
|
+
Useful for troubleshooting Lambda invocations.
|
|
237
|
+
|
|
238
|
+
Note: Event payloads are sanitized to remove sensitive fields before logging.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
True if event logging is enabled, False otherwise.
|
|
242
|
+
"""
|
|
243
|
+
value = os.getenv("LOG_LAMBDA_EVENTS", "false").lower() == "true"
|
|
244
|
+
return value
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Dict
|
|
1
3
|
from aws_lambda_powertools import Logger
|
|
2
4
|
from geek_cafe_saas_sdk.utilities.environment_variables import (
|
|
3
5
|
EnvironmentVariables,
|
|
@@ -48,6 +50,81 @@ class LoggingUtility:
|
|
|
48
50
|
"metric_filter": metric_filter,
|
|
49
51
|
}
|
|
50
52
|
return response
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def sanitize_event_for_logging(event: Dict[str, Any]) -> Dict[str, Any]:
|
|
56
|
+
"""
|
|
57
|
+
Sanitize a Lambda event dictionary by masking sensitive fields.
|
|
58
|
+
|
|
59
|
+
This removes or masks sensitive information like:
|
|
60
|
+
- Authorization headers
|
|
61
|
+
- API keys
|
|
62
|
+
- Passwords
|
|
63
|
+
- Tokens
|
|
64
|
+
- SSNs, credit cards, etc.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
event: The Lambda event dictionary to sanitize
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
A sanitized copy of the event safe for logging
|
|
71
|
+
"""
|
|
72
|
+
if not isinstance(event, dict):
|
|
73
|
+
return event
|
|
74
|
+
|
|
75
|
+
# Fields that should be completely removed
|
|
76
|
+
REMOVE_FIELDS = {
|
|
77
|
+
'password', 'passwd', 'pwd',
|
|
78
|
+
'secret', 'api_key', 'apikey',
|
|
79
|
+
'token', 'access_token', 'refresh_token',
|
|
80
|
+
'private_key', 'privatekey',
|
|
81
|
+
'ssn', 'credit_card', 'creditcard',
|
|
82
|
+
'cvv', 'pin'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Fields that should be masked (show first few chars)
|
|
86
|
+
MASK_FIELDS = {
|
|
87
|
+
'authorization', 'x_api_key',
|
|
88
|
+
'cookie', 'session'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
def sanitize_value(key: str, value: Any) -> Any:
|
|
92
|
+
"""Recursively sanitize values."""
|
|
93
|
+
try:
|
|
94
|
+
key_lower = key.lower().replace('-', '_').replace(' ', '_')
|
|
95
|
+
|
|
96
|
+
# Remove sensitive fields entirely (exact match)
|
|
97
|
+
if key_lower in REMOVE_FIELDS:
|
|
98
|
+
return '[REDACTED]'
|
|
99
|
+
|
|
100
|
+
# Mask partially visible fields (exact match)
|
|
101
|
+
if key_lower in MASK_FIELDS:
|
|
102
|
+
if isinstance(value, str) and len(value) > 20:
|
|
103
|
+
return f"{value[:4]}...{value[-4:]}"
|
|
104
|
+
return '[MASKED]'
|
|
105
|
+
|
|
106
|
+
# Recursively handle nested structures
|
|
107
|
+
if isinstance(value, dict):
|
|
108
|
+
return {k: sanitize_value(k, v) for k, v in value.items()}
|
|
109
|
+
elif isinstance(value, list):
|
|
110
|
+
return [sanitize_value(key, item) if isinstance(item, dict) else item
|
|
111
|
+
for item in value]
|
|
112
|
+
|
|
113
|
+
return value
|
|
114
|
+
except Exception:
|
|
115
|
+
# If we can't safely process the value, redact it
|
|
116
|
+
return '[SANITIZATION_ERROR]'
|
|
117
|
+
|
|
118
|
+
# Create a deep copy and sanitize
|
|
119
|
+
try:
|
|
120
|
+
sanitized = {k: sanitize_value(k, v) for k, v in event.items()}
|
|
121
|
+
# Validate that the result is JSON-serializable
|
|
122
|
+
import json
|
|
123
|
+
json.dumps(sanitized)
|
|
124
|
+
return sanitized
|
|
125
|
+
except Exception as e:
|
|
126
|
+
# If sanitization fails, return safe fallback
|
|
127
|
+
return {"error": "Failed to sanitize event"}
|
|
51
128
|
|
|
52
129
|
|
|
53
130
|
class LogLevels:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: geek_cafe_saas_sdk
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: Base Reusable Services for SaaS
|
|
5
5
|
Project-URL: Homepage, https://github.com/geekcafe/geek-cafe-services
|
|
6
6
|
Project-URL: Documentation, https://github.com/geekcafe/geek-cafe-services/blob/main/README.md
|