geek-cafe-saas-sdk 0.7.0__py3-none-any.whl → 0.7.2__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 +60 -136
  8. geek_cafe_saas_sdk/domains/files/services/file_system_service.py +43 -104
  9. geek_cafe_saas_sdk/domains/files/services/file_version_service.py +57 -131
  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.2.dist-info}/METADATA +1 -1
  77. {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.2.dist-info}/RECORD +79 -20
  78. {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.2.dist-info}/WHEEL +0 -0
  79. {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.2.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)