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,569 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plan model for subscription tier definitions.
|
|
3
|
+
|
|
4
|
+
Geek Cafe, LLC
|
|
5
|
+
MIT License. See Project Root for the license information.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import datetime as dt
|
|
9
|
+
from typing import Dict, Any, Optional, List
|
|
10
|
+
from geek_cafe_saas_sdk.models.base_model import BaseModel
|
|
11
|
+
from boto3_assist.dynamodb.dynamodb_index import DynamoDBIndex, DynamoDBKey
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Plan(BaseModel):
|
|
15
|
+
"""
|
|
16
|
+
Subscription plan/tier definition.
|
|
17
|
+
|
|
18
|
+
Represents a platform-wide subscription tier (Free, Pro, Enterprise, etc.)
|
|
19
|
+
with pricing, features, and limits. Plans are templates that get applied
|
|
20
|
+
to tenant Subscriptions.
|
|
21
|
+
|
|
22
|
+
Key Features:
|
|
23
|
+
- Multiple pricing tiers (month/year)
|
|
24
|
+
- Feature flags and limits
|
|
25
|
+
- Trial period configuration
|
|
26
|
+
- Addon compatibility
|
|
27
|
+
- Version tracking for plan changes
|
|
28
|
+
|
|
29
|
+
Examples:
|
|
30
|
+
- Free Plan: $0, 100 items, basic features
|
|
31
|
+
- Pro Plan: $29/mo, unlimited items, advanced features
|
|
32
|
+
- Enterprise Plan: Custom pricing, white-label, priority support
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
# Status constants
|
|
36
|
+
STATUS_ACTIVE = "active"
|
|
37
|
+
STATUS_ARCHIVED = "archived"
|
|
38
|
+
STATUS_DRAFT = "draft"
|
|
39
|
+
|
|
40
|
+
def __init__(self):
|
|
41
|
+
super().__init__()
|
|
42
|
+
|
|
43
|
+
# Plan identification
|
|
44
|
+
self._plan_code: str = "" # Unique identifier (e.g., "pro", "enterprise")
|
|
45
|
+
self._plan_name: str = "" # Display name
|
|
46
|
+
self._description: Optional[str] = None
|
|
47
|
+
self._tagline: Optional[str] = None # Short marketing copy
|
|
48
|
+
|
|
49
|
+
# Status and visibility
|
|
50
|
+
self._status: str = self.STATUS_DRAFT
|
|
51
|
+
self._is_public: bool = True # Show in pricing page
|
|
52
|
+
self._is_featured: bool = False # Highlight in UI
|
|
53
|
+
self._sort_order: int = 0 # Display ordering
|
|
54
|
+
|
|
55
|
+
# Pricing - Monthly
|
|
56
|
+
self._price_monthly_cents: int = 0
|
|
57
|
+
self._price_monthly_currency: str = "USD"
|
|
58
|
+
|
|
59
|
+
# Pricing - Annual (optional)
|
|
60
|
+
self._price_annual_cents: Optional[int] = None
|
|
61
|
+
self._price_annual_currency: str = "USD"
|
|
62
|
+
self._annual_discount_percentage: Optional[float] = None # e.g., 20.0 for 20% off
|
|
63
|
+
|
|
64
|
+
# Trial configuration
|
|
65
|
+
self._trial_days: int = 0 # Number of trial days (0 = no trial)
|
|
66
|
+
self._trial_requires_payment_method: bool = True
|
|
67
|
+
|
|
68
|
+
# Seat/user configuration
|
|
69
|
+
self._min_seats: int = 1
|
|
70
|
+
self._max_seats: Optional[int] = None # None = unlimited
|
|
71
|
+
self._price_per_additional_seat_cents: int = 0
|
|
72
|
+
|
|
73
|
+
# Feature flags (boolean features)
|
|
74
|
+
self._features: Dict[str, bool] = {}
|
|
75
|
+
# Example: {"api_access": True, "white_label": False, "sso": True}
|
|
76
|
+
|
|
77
|
+
# Numeric limits
|
|
78
|
+
self._limits: Dict[str, int] = {}
|
|
79
|
+
# Example: {"max_projects": 10, "max_storage_gb": 100, "max_api_calls_per_day": 1000}
|
|
80
|
+
|
|
81
|
+
# Addon compatibility
|
|
82
|
+
self._included_addon_ids: List[str] = [] # Addons included in base price
|
|
83
|
+
self._compatible_addon_ids: List[str] = [] # Addons that can be added
|
|
84
|
+
|
|
85
|
+
# Metadata for display
|
|
86
|
+
self._feature_list: List[str] = [] # Marketing feature list
|
|
87
|
+
self._cta_text: str = "Get Started" # Call-to-action button text
|
|
88
|
+
self._recommended: bool = False # "Most Popular" badge
|
|
89
|
+
|
|
90
|
+
# Version tracking
|
|
91
|
+
self._version: int = 1
|
|
92
|
+
self._previous_version_id: Optional[str] = None
|
|
93
|
+
|
|
94
|
+
# Grandfathering
|
|
95
|
+
self._allow_downgrades: bool = True
|
|
96
|
+
self._allow_upgrades: bool = True
|
|
97
|
+
|
|
98
|
+
# CRITICAL: Call _setup_indexes() as LAST line in __init__
|
|
99
|
+
self._setup_indexes()
|
|
100
|
+
|
|
101
|
+
def _setup_indexes(self):
|
|
102
|
+
"""Setup DynamoDB indexes for plan queries."""
|
|
103
|
+
|
|
104
|
+
# Primary index: Plan by ID
|
|
105
|
+
primary: DynamoDBIndex = DynamoDBIndex()
|
|
106
|
+
primary.name = "primary"
|
|
107
|
+
primary.partition_key.attribute_name = "pk"
|
|
108
|
+
primary.partition_key.value = lambda: DynamoDBKey.build_key(("plan", self.id))
|
|
109
|
+
primary.sort_key.attribute_name = "sk"
|
|
110
|
+
primary.sort_key.value = lambda: "metadata"
|
|
111
|
+
self.indexes.add_primary(primary)
|
|
112
|
+
|
|
113
|
+
# GSI1: Plans by status (for listing active/archived plans)
|
|
114
|
+
gsi: DynamoDBIndex = DynamoDBIndex()
|
|
115
|
+
gsi.name = "gsi1"
|
|
116
|
+
gsi.partition_key.attribute_name = f"{gsi.name}_pk"
|
|
117
|
+
gsi.partition_key.value = lambda: DynamoDBKey.build_key(("status", self.status))
|
|
118
|
+
gsi.sort_key.attribute_name = f"{gsi.name}_sk"
|
|
119
|
+
gsi.sort_key.value = lambda: DynamoDBKey.build_key(("sort_order", self.sort_order), ("name", self.plan_name))
|
|
120
|
+
self.indexes.add_secondary(gsi)
|
|
121
|
+
|
|
122
|
+
# GSI2: Plans by plan_code (for lookups by code)
|
|
123
|
+
gsi: DynamoDBIndex = DynamoDBIndex()
|
|
124
|
+
gsi.name = "gsi2"
|
|
125
|
+
gsi.partition_key.attribute_name = f"{gsi.name}_pk"
|
|
126
|
+
gsi.partition_key.value = lambda: "PLAN"
|
|
127
|
+
gsi.sort_key.attribute_name = f"{gsi.name}_sk"
|
|
128
|
+
gsi.sort_key.value = lambda: DynamoDBKey.build_key(("code", self.plan_code))
|
|
129
|
+
self.indexes.add_secondary(gsi)
|
|
130
|
+
|
|
131
|
+
# Plan Code
|
|
132
|
+
@property
|
|
133
|
+
def plan_code(self) -> str:
|
|
134
|
+
"""Unique plan identifier code."""
|
|
135
|
+
return self._plan_code
|
|
136
|
+
|
|
137
|
+
@plan_code.setter
|
|
138
|
+
def plan_code(self, value: str):
|
|
139
|
+
if not value:
|
|
140
|
+
raise ValueError("plan_code is required")
|
|
141
|
+
self._plan_code = value.lower().strip()
|
|
142
|
+
|
|
143
|
+
# Plan Name
|
|
144
|
+
@property
|
|
145
|
+
def plan_name(self) -> str:
|
|
146
|
+
"""Display name for plan."""
|
|
147
|
+
return self._plan_name
|
|
148
|
+
|
|
149
|
+
@plan_name.setter
|
|
150
|
+
def plan_name(self, value: str):
|
|
151
|
+
if not value:
|
|
152
|
+
raise ValueError("plan_name is required")
|
|
153
|
+
self._plan_name = value.strip()
|
|
154
|
+
|
|
155
|
+
# Description
|
|
156
|
+
@property
|
|
157
|
+
def description(self) -> Optional[str]:
|
|
158
|
+
"""Detailed plan description."""
|
|
159
|
+
return self._description
|
|
160
|
+
|
|
161
|
+
@description.setter
|
|
162
|
+
def description(self, value: Optional[str]):
|
|
163
|
+
self._description = value
|
|
164
|
+
|
|
165
|
+
# Tagline
|
|
166
|
+
@property
|
|
167
|
+
def tagline(self) -> Optional[str]:
|
|
168
|
+
"""Short marketing tagline."""
|
|
169
|
+
return self._tagline
|
|
170
|
+
|
|
171
|
+
@tagline.setter
|
|
172
|
+
def tagline(self, value: Optional[str]):
|
|
173
|
+
self._tagline = value
|
|
174
|
+
|
|
175
|
+
# Status
|
|
176
|
+
@property
|
|
177
|
+
def status(self) -> str:
|
|
178
|
+
"""Plan status."""
|
|
179
|
+
return self._status
|
|
180
|
+
|
|
181
|
+
@status.setter
|
|
182
|
+
def status(self, value: str):
|
|
183
|
+
valid_statuses = [self.STATUS_ACTIVE, self.STATUS_ARCHIVED, self.STATUS_DRAFT]
|
|
184
|
+
if value not in valid_statuses:
|
|
185
|
+
raise ValueError(f"Invalid status: {value}. Must be one of {valid_statuses}")
|
|
186
|
+
self._status = value
|
|
187
|
+
|
|
188
|
+
# Is Public
|
|
189
|
+
@property
|
|
190
|
+
def is_public(self) -> bool:
|
|
191
|
+
"""Whether plan is shown publicly."""
|
|
192
|
+
return self._is_public
|
|
193
|
+
|
|
194
|
+
@is_public.setter
|
|
195
|
+
def is_public(self, value: bool):
|
|
196
|
+
self._is_public = value
|
|
197
|
+
|
|
198
|
+
# Is Featured
|
|
199
|
+
@property
|
|
200
|
+
def is_featured(self) -> bool:
|
|
201
|
+
"""Whether plan is featured/highlighted."""
|
|
202
|
+
return self._is_featured
|
|
203
|
+
|
|
204
|
+
@is_featured.setter
|
|
205
|
+
def is_featured(self, value: bool):
|
|
206
|
+
self._is_featured = value
|
|
207
|
+
|
|
208
|
+
# Sort Order
|
|
209
|
+
@property
|
|
210
|
+
def sort_order(self) -> int:
|
|
211
|
+
"""Display sort order."""
|
|
212
|
+
return self._sort_order
|
|
213
|
+
|
|
214
|
+
@sort_order.setter
|
|
215
|
+
def sort_order(self, value: int):
|
|
216
|
+
self._sort_order = value
|
|
217
|
+
|
|
218
|
+
# Price Monthly Cents
|
|
219
|
+
@property
|
|
220
|
+
def price_monthly_cents(self) -> int:
|
|
221
|
+
"""Monthly price in cents."""
|
|
222
|
+
return self._price_monthly_cents
|
|
223
|
+
|
|
224
|
+
@price_monthly_cents.setter
|
|
225
|
+
def price_monthly_cents(self, value: int):
|
|
226
|
+
if value < 0:
|
|
227
|
+
raise ValueError("price_monthly_cents cannot be negative")
|
|
228
|
+
self._price_monthly_cents = value
|
|
229
|
+
|
|
230
|
+
# Price Monthly Currency
|
|
231
|
+
@property
|
|
232
|
+
def price_monthly_currency(self) -> str:
|
|
233
|
+
"""Monthly price currency code."""
|
|
234
|
+
return self._price_monthly_currency
|
|
235
|
+
|
|
236
|
+
@price_monthly_currency.setter
|
|
237
|
+
def price_monthly_currency(self, value: str):
|
|
238
|
+
self._price_monthly_currency = value.upper() if value else "USD"
|
|
239
|
+
|
|
240
|
+
# Price Annual Cents
|
|
241
|
+
@property
|
|
242
|
+
def price_annual_cents(self) -> Optional[int]:
|
|
243
|
+
"""Annual price in cents."""
|
|
244
|
+
return self._price_annual_cents
|
|
245
|
+
|
|
246
|
+
@price_annual_cents.setter
|
|
247
|
+
def price_annual_cents(self, value: Optional[int]):
|
|
248
|
+
if value is not None and value < 0:
|
|
249
|
+
raise ValueError("price_annual_cents cannot be negative")
|
|
250
|
+
self._price_annual_cents = value
|
|
251
|
+
|
|
252
|
+
# Price Annual Currency
|
|
253
|
+
@property
|
|
254
|
+
def price_annual_currency(self) -> str:
|
|
255
|
+
"""Annual price currency code."""
|
|
256
|
+
return self._price_annual_currency
|
|
257
|
+
|
|
258
|
+
@price_annual_currency.setter
|
|
259
|
+
def price_annual_currency(self, value: str):
|
|
260
|
+
self._price_annual_currency = value.upper() if value else "USD"
|
|
261
|
+
|
|
262
|
+
# Annual Discount Percentage
|
|
263
|
+
@property
|
|
264
|
+
def annual_discount_percentage(self) -> Optional[float]:
|
|
265
|
+
"""Annual billing discount percentage."""
|
|
266
|
+
return self._annual_discount_percentage
|
|
267
|
+
|
|
268
|
+
@annual_discount_percentage.setter
|
|
269
|
+
def annual_discount_percentage(self, value: Optional[float]):
|
|
270
|
+
if value is not None and (value < 0 or value > 100):
|
|
271
|
+
raise ValueError("annual_discount_percentage must be between 0 and 100")
|
|
272
|
+
self._annual_discount_percentage = value
|
|
273
|
+
|
|
274
|
+
# Trial Days
|
|
275
|
+
@property
|
|
276
|
+
def trial_days(self) -> int:
|
|
277
|
+
"""Number of trial days."""
|
|
278
|
+
return self._trial_days
|
|
279
|
+
|
|
280
|
+
@trial_days.setter
|
|
281
|
+
def trial_days(self, value: int):
|
|
282
|
+
if value < 0:
|
|
283
|
+
raise ValueError("trial_days cannot be negative")
|
|
284
|
+
self._trial_days = value
|
|
285
|
+
|
|
286
|
+
# Trial Requires Payment Method
|
|
287
|
+
@property
|
|
288
|
+
def trial_requires_payment_method(self) -> bool:
|
|
289
|
+
"""Whether trial requires payment method upfront."""
|
|
290
|
+
return self._trial_requires_payment_method
|
|
291
|
+
|
|
292
|
+
@trial_requires_payment_method.setter
|
|
293
|
+
def trial_requires_payment_method(self, value: bool):
|
|
294
|
+
self._trial_requires_payment_method = value
|
|
295
|
+
|
|
296
|
+
# Min Seats
|
|
297
|
+
@property
|
|
298
|
+
def min_seats(self) -> int:
|
|
299
|
+
"""Minimum number of seats."""
|
|
300
|
+
return self._min_seats
|
|
301
|
+
|
|
302
|
+
@min_seats.setter
|
|
303
|
+
def min_seats(self, value: int):
|
|
304
|
+
if value < 1:
|
|
305
|
+
raise ValueError("min_seats must be at least 1")
|
|
306
|
+
self._min_seats = value
|
|
307
|
+
|
|
308
|
+
# Max Seats
|
|
309
|
+
@property
|
|
310
|
+
def max_seats(self) -> Optional[int]:
|
|
311
|
+
"""Maximum number of seats (None = unlimited)."""
|
|
312
|
+
return self._max_seats
|
|
313
|
+
|
|
314
|
+
@max_seats.setter
|
|
315
|
+
def max_seats(self, value: Optional[int]):
|
|
316
|
+
if value is not None and value < 1:
|
|
317
|
+
raise ValueError("max_seats must be at least 1")
|
|
318
|
+
self._max_seats = value
|
|
319
|
+
|
|
320
|
+
# Price Per Additional Seat Cents
|
|
321
|
+
@property
|
|
322
|
+
def price_per_additional_seat_cents(self) -> int:
|
|
323
|
+
"""Price per additional seat in cents."""
|
|
324
|
+
return self._price_per_additional_seat_cents
|
|
325
|
+
|
|
326
|
+
@price_per_additional_seat_cents.setter
|
|
327
|
+
def price_per_additional_seat_cents(self, value: int):
|
|
328
|
+
if value < 0:
|
|
329
|
+
raise ValueError("price_per_additional_seat_cents cannot be negative")
|
|
330
|
+
self._price_per_additional_seat_cents = value
|
|
331
|
+
|
|
332
|
+
# Features
|
|
333
|
+
@property
|
|
334
|
+
def features(self) -> Dict[str, bool]:
|
|
335
|
+
"""Feature flags dictionary."""
|
|
336
|
+
return self._features
|
|
337
|
+
|
|
338
|
+
@features.setter
|
|
339
|
+
def features(self, value: Dict[str, bool]):
|
|
340
|
+
self._features = value if value else {}
|
|
341
|
+
|
|
342
|
+
# Limits
|
|
343
|
+
@property
|
|
344
|
+
def limits(self) -> Dict[str, int]:
|
|
345
|
+
"""Numeric limits dictionary."""
|
|
346
|
+
return self._limits
|
|
347
|
+
|
|
348
|
+
@limits.setter
|
|
349
|
+
def limits(self, value: Dict[str, int]):
|
|
350
|
+
self._limits = value if value else {}
|
|
351
|
+
|
|
352
|
+
# Included Addon IDs
|
|
353
|
+
@property
|
|
354
|
+
def included_addon_ids(self) -> List[str]:
|
|
355
|
+
"""List of included addon IDs."""
|
|
356
|
+
return self._included_addon_ids
|
|
357
|
+
|
|
358
|
+
@included_addon_ids.setter
|
|
359
|
+
def included_addon_ids(self, value: List[str]):
|
|
360
|
+
self._included_addon_ids = value if value else []
|
|
361
|
+
|
|
362
|
+
# Compatible Addon IDs
|
|
363
|
+
@property
|
|
364
|
+
def compatible_addon_ids(self) -> List[str]:
|
|
365
|
+
"""List of compatible addon IDs."""
|
|
366
|
+
return self._compatible_addon_ids
|
|
367
|
+
|
|
368
|
+
@compatible_addon_ids.setter
|
|
369
|
+
def compatible_addon_ids(self, value: List[str]):
|
|
370
|
+
self._compatible_addon_ids = value if value else []
|
|
371
|
+
|
|
372
|
+
# Feature List
|
|
373
|
+
@property
|
|
374
|
+
def feature_list(self) -> List[str]:
|
|
375
|
+
"""Marketing feature list."""
|
|
376
|
+
return self._feature_list
|
|
377
|
+
|
|
378
|
+
@feature_list.setter
|
|
379
|
+
def feature_list(self, value: List[str]):
|
|
380
|
+
self._feature_list = value if value else []
|
|
381
|
+
|
|
382
|
+
# CTA Text
|
|
383
|
+
@property
|
|
384
|
+
def cta_text(self) -> str:
|
|
385
|
+
"""Call-to-action button text."""
|
|
386
|
+
return self._cta_text
|
|
387
|
+
|
|
388
|
+
@cta_text.setter
|
|
389
|
+
def cta_text(self, value: str):
|
|
390
|
+
self._cta_text = value if value else "Get Started"
|
|
391
|
+
|
|
392
|
+
# Recommended
|
|
393
|
+
@property
|
|
394
|
+
def recommended(self) -> bool:
|
|
395
|
+
"""Whether plan is marked as recommended."""
|
|
396
|
+
return self._recommended
|
|
397
|
+
|
|
398
|
+
@recommended.setter
|
|
399
|
+
def recommended(self, value: bool):
|
|
400
|
+
self._recommended = value
|
|
401
|
+
|
|
402
|
+
# Version
|
|
403
|
+
@property
|
|
404
|
+
def version(self) -> int:
|
|
405
|
+
"""Plan version number."""
|
|
406
|
+
return self._version
|
|
407
|
+
|
|
408
|
+
@version.setter
|
|
409
|
+
def version(self, value: int):
|
|
410
|
+
self._version = value
|
|
411
|
+
|
|
412
|
+
# Previous Version ID
|
|
413
|
+
@property
|
|
414
|
+
def previous_version_id(self) -> Optional[str]:
|
|
415
|
+
"""ID of previous plan version."""
|
|
416
|
+
return self._previous_version_id
|
|
417
|
+
|
|
418
|
+
@previous_version_id.setter
|
|
419
|
+
def previous_version_id(self, value: Optional[str]):
|
|
420
|
+
self._previous_version_id = value
|
|
421
|
+
|
|
422
|
+
# Allow Downgrades
|
|
423
|
+
@property
|
|
424
|
+
def allow_downgrades(self) -> bool:
|
|
425
|
+
"""Whether downgrades from this plan are allowed."""
|
|
426
|
+
return self._allow_downgrades
|
|
427
|
+
|
|
428
|
+
@allow_downgrades.setter
|
|
429
|
+
def allow_downgrades(self, value: bool):
|
|
430
|
+
self._allow_downgrades = value
|
|
431
|
+
|
|
432
|
+
# Allow Upgrades
|
|
433
|
+
@property
|
|
434
|
+
def allow_upgrades(self) -> bool:
|
|
435
|
+
"""Whether upgrades to this plan are allowed."""
|
|
436
|
+
return self._allow_upgrades
|
|
437
|
+
|
|
438
|
+
@allow_upgrades.setter
|
|
439
|
+
def allow_upgrades(self, value: bool):
|
|
440
|
+
self._allow_upgrades = value
|
|
441
|
+
|
|
442
|
+
# Helper Methods
|
|
443
|
+
|
|
444
|
+
def is_active(self) -> bool:
|
|
445
|
+
"""Check if plan is active."""
|
|
446
|
+
return self._status == self.STATUS_ACTIVE
|
|
447
|
+
|
|
448
|
+
def is_archived(self) -> bool:
|
|
449
|
+
"""Check if plan is archived."""
|
|
450
|
+
return self._status == self.STATUS_ARCHIVED
|
|
451
|
+
|
|
452
|
+
def is_draft(self) -> bool:
|
|
453
|
+
"""Check if plan is in draft status."""
|
|
454
|
+
return self._status == self.STATUS_DRAFT
|
|
455
|
+
|
|
456
|
+
def is_free(self) -> bool:
|
|
457
|
+
"""Check if plan is free."""
|
|
458
|
+
return self._price_monthly_cents == 0
|
|
459
|
+
|
|
460
|
+
def has_trial(self) -> bool:
|
|
461
|
+
"""Check if plan has a trial period."""
|
|
462
|
+
return self._trial_days > 0
|
|
463
|
+
|
|
464
|
+
def has_annual_pricing(self) -> bool:
|
|
465
|
+
"""Check if plan has annual pricing option."""
|
|
466
|
+
return self._price_annual_cents is not None
|
|
467
|
+
|
|
468
|
+
def get_monthly_price_dollars(self) -> float:
|
|
469
|
+
"""Get monthly price in dollars."""
|
|
470
|
+
return self._price_monthly_cents / 100.0
|
|
471
|
+
|
|
472
|
+
def get_annual_price_dollars(self) -> Optional[float]:
|
|
473
|
+
"""Get annual price in dollars."""
|
|
474
|
+
if self._price_annual_cents is None:
|
|
475
|
+
return None
|
|
476
|
+
return self._price_annual_cents / 100.0
|
|
477
|
+
|
|
478
|
+
def calculate_annual_savings_cents(self) -> int:
|
|
479
|
+
"""Calculate annual savings vs monthly billing."""
|
|
480
|
+
if not self.has_annual_pricing():
|
|
481
|
+
return 0
|
|
482
|
+
monthly_total = self._price_monthly_cents * 12
|
|
483
|
+
return monthly_total - self._price_annual_cents
|
|
484
|
+
|
|
485
|
+
def get_annual_savings_percentage(self) -> float:
|
|
486
|
+
"""Calculate annual savings percentage."""
|
|
487
|
+
if not self.has_annual_pricing():
|
|
488
|
+
return 0.0
|
|
489
|
+
monthly_total = self._price_monthly_cents * 12
|
|
490
|
+
if monthly_total == 0:
|
|
491
|
+
return 0.0
|
|
492
|
+
savings = self.calculate_annual_savings_cents()
|
|
493
|
+
return (savings / monthly_total) * 100.0
|
|
494
|
+
|
|
495
|
+
def has_feature(self, feature_key: str) -> bool:
|
|
496
|
+
"""Check if plan has a specific feature."""
|
|
497
|
+
return self._features.get(feature_key, False)
|
|
498
|
+
|
|
499
|
+
def get_limit(self, limit_key: str, default: int = 0) -> int:
|
|
500
|
+
"""Get a specific limit value."""
|
|
501
|
+
return self._limits.get(limit_key, default)
|
|
502
|
+
|
|
503
|
+
def has_unlimited_limit(self, limit_key: str) -> bool:
|
|
504
|
+
"""Check if a limit is unlimited (-1 convention)."""
|
|
505
|
+
return self.get_limit(limit_key, 0) == -1
|
|
506
|
+
|
|
507
|
+
def includes_addon(self, addon_id: str) -> bool:
|
|
508
|
+
"""Check if addon is included in plan."""
|
|
509
|
+
return addon_id in self._included_addon_ids
|
|
510
|
+
|
|
511
|
+
def is_addon_compatible(self, addon_id: str) -> bool:
|
|
512
|
+
"""Check if addon can be added to plan."""
|
|
513
|
+
return addon_id in self._compatible_addon_ids or addon_id in self._included_addon_ids
|
|
514
|
+
|
|
515
|
+
def calculate_price_for_seats(self, seat_count: int, annual: bool = False) -> int:
|
|
516
|
+
"""
|
|
517
|
+
Calculate total price for given number of seats.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
seat_count: Number of seats
|
|
521
|
+
annual: Whether to use annual pricing
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
Price in cents
|
|
525
|
+
"""
|
|
526
|
+
if seat_count < self._min_seats:
|
|
527
|
+
seat_count = self._min_seats
|
|
528
|
+
|
|
529
|
+
if self._max_seats and seat_count > self._max_seats:
|
|
530
|
+
seat_count = self._max_seats
|
|
531
|
+
|
|
532
|
+
base_price = self._price_annual_cents if annual and self.has_annual_pricing() else self._price_monthly_cents
|
|
533
|
+
|
|
534
|
+
if seat_count <= self._min_seats:
|
|
535
|
+
return base_price
|
|
536
|
+
|
|
537
|
+
additional_seats = seat_count - self._min_seats
|
|
538
|
+
additional_cost = additional_seats * self._price_per_additional_seat_cents
|
|
539
|
+
|
|
540
|
+
return base_price + additional_cost
|
|
541
|
+
|
|
542
|
+
def validate(self) -> tuple[bool, List[str]]:
|
|
543
|
+
"""
|
|
544
|
+
Validate plan data.
|
|
545
|
+
|
|
546
|
+
Returns:
|
|
547
|
+
Tuple of (is_valid, error_messages)
|
|
548
|
+
"""
|
|
549
|
+
errors = []
|
|
550
|
+
|
|
551
|
+
if not self._plan_code:
|
|
552
|
+
errors.append("plan_code is required")
|
|
553
|
+
|
|
554
|
+
if not self._plan_name:
|
|
555
|
+
errors.append("plan_name is required")
|
|
556
|
+
|
|
557
|
+
if self._price_monthly_cents < 0:
|
|
558
|
+
errors.append("price_monthly_cents cannot be negative")
|
|
559
|
+
|
|
560
|
+
if self._price_annual_cents is not None and self._price_annual_cents < 0:
|
|
561
|
+
errors.append("price_annual_cents cannot be negative")
|
|
562
|
+
|
|
563
|
+
if self._min_seats < 1:
|
|
564
|
+
errors.append("min_seats must be at least 1")
|
|
565
|
+
|
|
566
|
+
if self._max_seats and self._max_seats < self._min_seats:
|
|
567
|
+
errors.append("max_seats must be >= min_seats")
|
|
568
|
+
|
|
569
|
+
return (len(errors) == 0, errors)
|