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,604 @@
1
+ """
2
+ Addon model for subscription add-on modules.
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 Addon(BaseModel):
15
+ """
16
+ Subscription addon/module definition.
17
+
18
+ Represents a billable feature or module that can be added to subscriptions
19
+ (e.g., Chat Module, Voting Module, Extra Storage, etc.)
20
+
21
+ Key Features:
22
+ - Fixed or per-unit pricing
23
+ - Feature flags and limits
24
+ - Plan compatibility
25
+ - Usage tracking support
26
+ - Trial periods
27
+
28
+ Pricing Models:
29
+ - Fixed: Flat monthly fee (e.g., $10/month for chat)
30
+ - Per Unit: Price per unit (e.g., $0.01 per GB storage)
31
+ - Tiered: Different rates based on usage tiers
32
+
33
+ Examples:
34
+ - Chat Module: $15/mo fixed
35
+ - Extra Storage: $0.10 per GB/month
36
+ - Priority Support: $50/mo fixed
37
+ - API Calls: $0.001 per call (metered)
38
+ """
39
+
40
+ # Status constants
41
+ STATUS_ACTIVE = "active"
42
+ STATUS_ARCHIVED = "archived"
43
+ STATUS_DRAFT = "draft"
44
+
45
+ # Pricing model constants
46
+ PRICING_FIXED = "fixed" # Flat monthly fee
47
+ PRICING_PER_UNIT = "per_unit" # Price per unit (metered)
48
+ PRICING_TIERED = "tiered" # Different rates by tier
49
+
50
+ def __init__(self):
51
+ super().__init__()
52
+
53
+ # Addon identification
54
+ self._addon_code: str = "" # Unique identifier (e.g., "chat", "extra_storage")
55
+ self._addon_name: str = "" # Display name
56
+ self._description: Optional[str] = None
57
+ self._category: Optional[str] = None # e.g., "communication", "storage", "features"
58
+
59
+ # Status and visibility
60
+ self._status: str = self.STATUS_DRAFT
61
+ self._is_public: bool = True
62
+ self._sort_order: Optional[int] = None
63
+
64
+ # Pricing model
65
+ self._pricing_model: str = self.PRICING_FIXED
66
+
67
+ # Fixed pricing
68
+ self._price_monthly_cents: int = 0
69
+ self._price_annual_cents: Optional[int] = None
70
+ self._currency: str = "USD"
71
+
72
+ # Per-unit pricing
73
+ self._price_per_unit_cents: int = 0 # For per_unit model
74
+ self._unit_name: Optional[str] = None # e.g., "GB", "seat", "call"
75
+ self._included_units: int = 0 # Free units included
76
+ self._min_units: int = 0 # Minimum billable units
77
+ self._max_units: Optional[int] = None # Maximum allowed units
78
+
79
+ # Tiered pricing
80
+ self._pricing_tiers: List[Dict[str, Any]] = []
81
+ # Example: [{"from": 0, "to": 100, "price_cents": 1000}, ...]
82
+
83
+ # Trial configuration
84
+ self._trial_days: int = 0
85
+
86
+ # Feature flags
87
+ self._features: Dict[str, bool] = {}
88
+
89
+ # Limits
90
+ self._limits: Dict[str, int] = {}
91
+
92
+ # Plan compatibility
93
+ self._compatible_plan_codes: List[str] = [] # Empty = all plans
94
+ self._incompatible_addon_codes: List[str] = [] # Mutually exclusive addons
95
+
96
+ # Metadata
97
+ self._feature_list: List[str] = []
98
+ self._icon: Optional[str] = None # Icon name/URL
99
+ self._color: Optional[str] = None # Brand color
100
+
101
+ # Metering
102
+ self._is_metered: bool = False # Requires usage tracking
103
+ self._meter_event_name: Optional[str] = None # Event to track
104
+ self._billing_scheme: str = "per_month" # per_month, per_year, per_use
105
+
106
+ # Version tracking
107
+ self._version: int = 1
108
+ self._previous_version_id: Optional[str] = None
109
+
110
+ # CRITICAL: Call _setup_indexes() as LAST line in __init__
111
+ self._setup_indexes()
112
+
113
+ def _setup_indexes(self):
114
+ """Setup DynamoDB indexes for addon queries."""
115
+
116
+ # Primary index: Addon by ID
117
+ primary: DynamoDBIndex = DynamoDBIndex()
118
+ primary.name = "primary"
119
+ primary.partition_key.attribute_name = "pk"
120
+ primary.partition_key.value = lambda: DynamoDBKey.build_key(("addon", self.id))
121
+ primary.sort_key.attribute_name = "sk"
122
+ primary.sort_key.value = lambda: "metadata"
123
+ self.indexes.add_primary(primary)
124
+
125
+ # GSI1: Addons by status and category
126
+ gsi: DynamoDBIndex = DynamoDBIndex()
127
+ gsi.name = "gsi1"
128
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
129
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(("status", self.status))
130
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
131
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(("category", self.category), ("sort", self.sort_order))
132
+ self.indexes.add_secondary(gsi)
133
+
134
+ # GSI2: Addons by addon_code
135
+ gsi: DynamoDBIndex = DynamoDBIndex()
136
+ gsi.name = "gsi2"
137
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
138
+ gsi.partition_key.value = lambda: "ADDON"
139
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
140
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(("code", self.addon_code))
141
+ self.indexes.add_secondary(gsi)
142
+
143
+ # Addon Code
144
+ @property
145
+ def addon_code(self) -> str:
146
+ """Unique addon identifier code."""
147
+ return self._addon_code
148
+
149
+ @addon_code.setter
150
+ def addon_code(self, value: str):
151
+ if not value:
152
+ raise ValueError("addon_code is required")
153
+ self._addon_code = value.lower().strip()
154
+
155
+ # Addon Name
156
+ @property
157
+ def addon_name(self) -> str:
158
+ """Display name for addon."""
159
+ return self._addon_name
160
+
161
+ @addon_name.setter
162
+ def addon_name(self, value: str):
163
+ if not value:
164
+ raise ValueError("addon_name is required")
165
+ self._addon_name = value.strip()
166
+
167
+ # Description
168
+ @property
169
+ def description(self) -> Optional[str]:
170
+ """Detailed addon description."""
171
+ return self._description
172
+
173
+ @description.setter
174
+ def description(self, value: Optional[str]):
175
+ self._description = value
176
+
177
+ # Category
178
+ @property
179
+ def category(self) -> Optional[str]:
180
+ """Addon category."""
181
+ return self._category
182
+
183
+ @category.setter
184
+ def category(self, value: Optional[str]):
185
+ self._category = value
186
+
187
+ # Status
188
+ @property
189
+ def status(self) -> str:
190
+ """Addon status."""
191
+ return self._status
192
+
193
+ @status.setter
194
+ def status(self, value: str):
195
+ valid_statuses = [self.STATUS_ACTIVE, self.STATUS_ARCHIVED, self.STATUS_DRAFT]
196
+ if value not in valid_statuses:
197
+ raise ValueError(f"Invalid status: {value}. Must be one of {valid_statuses}")
198
+ self._status = value
199
+
200
+ # Sort Order
201
+ @property
202
+ def sort_order(self) -> int:
203
+ """Sort order for display."""
204
+ return self._sort_order
205
+
206
+ @sort_order.setter
207
+ def sort_order(self, value: int):
208
+ self._sort_order = value
209
+
210
+ # Is Public
211
+ @property
212
+ def is_public(self) -> bool:
213
+ """Whether addon is publicly available."""
214
+ return self._is_public
215
+
216
+ @is_public.setter
217
+ def is_public(self, value: bool):
218
+ self._is_public = value
219
+
220
+ # Pricing Model
221
+ @property
222
+ def pricing_model(self) -> str:
223
+ """Pricing model type."""
224
+ return self._pricing_model
225
+
226
+ @pricing_model.setter
227
+ def pricing_model(self, value: str):
228
+ valid_models = [self.PRICING_FIXED, self.PRICING_PER_UNIT, self.PRICING_TIERED]
229
+ if value not in valid_models:
230
+ raise ValueError(f"Invalid pricing_model: {value}. Must be one of {valid_models}")
231
+ self._pricing_model = value
232
+
233
+ # Price Monthly Cents
234
+ @property
235
+ def price_monthly_cents(self) -> int:
236
+ """Monthly price in cents (fixed model)."""
237
+ return self._price_monthly_cents
238
+
239
+ @price_monthly_cents.setter
240
+ def price_monthly_cents(self, value: int):
241
+ if value < 0:
242
+ raise ValueError("price_monthly_cents cannot be negative")
243
+ self._price_monthly_cents = value
244
+
245
+ # Price Annual Cents
246
+ @property
247
+ def price_annual_cents(self) -> Optional[int]:
248
+ """Annual price in cents (fixed model)."""
249
+ return self._price_annual_cents
250
+
251
+ @price_annual_cents.setter
252
+ def price_annual_cents(self, value: Optional[int]):
253
+ if value is not None and value < 0:
254
+ raise ValueError("price_annual_cents cannot be negative")
255
+ self._price_annual_cents = value
256
+
257
+ # Currency
258
+ @property
259
+ def currency(self) -> str:
260
+ """Currency code."""
261
+ return self._currency
262
+
263
+ @currency.setter
264
+ def currency(self, value: str):
265
+ self._currency = value.upper() if value else "USD"
266
+
267
+ # Price Per Unit Cents
268
+ @property
269
+ def price_per_unit_cents(self) -> int:
270
+ """Price per unit in cents (per_unit model)."""
271
+ return self._price_per_unit_cents
272
+
273
+ @price_per_unit_cents.setter
274
+ def price_per_unit_cents(self, value: int):
275
+ if value < 0:
276
+ raise ValueError("price_per_unit_cents cannot be negative")
277
+ self._price_per_unit_cents = value
278
+
279
+ # Unit Name
280
+ @property
281
+ def unit_name(self) -> Optional[str]:
282
+ """Unit name for per_unit pricing."""
283
+ return self._unit_name
284
+
285
+ @unit_name.setter
286
+ def unit_name(self, value: Optional[str]):
287
+ self._unit_name = value
288
+
289
+ # Included Units
290
+ @property
291
+ def included_units(self) -> int:
292
+ """Free units included."""
293
+ return self._included_units
294
+
295
+ @included_units.setter
296
+ def included_units(self, value: int):
297
+ if value < 0:
298
+ raise ValueError("included_units cannot be negative")
299
+ self._included_units = value
300
+
301
+ # Min Units
302
+ @property
303
+ def min_units(self) -> int:
304
+ """Minimum billable units."""
305
+ return self._min_units
306
+
307
+ @min_units.setter
308
+ def min_units(self, value: int):
309
+ if value < 0:
310
+ raise ValueError("min_units cannot be negative")
311
+ self._min_units = value
312
+
313
+ # Max Units
314
+ @property
315
+ def max_units(self) -> Optional[int]:
316
+ """Maximum allowed units."""
317
+ return self._max_units
318
+
319
+ @max_units.setter
320
+ def max_units(self, value: Optional[int]):
321
+ if value is not None and value < 0:
322
+ raise ValueError("max_units cannot be negative")
323
+ self._max_units = value
324
+
325
+ # Pricing Tiers
326
+ @property
327
+ def pricing_tiers(self) -> List[Dict[str, Any]]:
328
+ """Tiered pricing configuration."""
329
+ return self._pricing_tiers
330
+
331
+ @pricing_tiers.setter
332
+ def pricing_tiers(self, value: List[Dict[str, Any]]):
333
+ self._pricing_tiers = value if value else []
334
+
335
+ # Trial Days
336
+ @property
337
+ def trial_days(self) -> int:
338
+ """Number of trial days."""
339
+ return self._trial_days
340
+
341
+ @trial_days.setter
342
+ def trial_days(self, value: int):
343
+ if value < 0:
344
+ raise ValueError("trial_days cannot be negative")
345
+ self._trial_days = value
346
+
347
+ # Features
348
+ @property
349
+ def features(self) -> Dict[str, bool]:
350
+ """Feature flags dictionary."""
351
+ return self._features
352
+
353
+ @features.setter
354
+ def features(self, value: Dict[str, bool]):
355
+ self._features = value if value else {}
356
+
357
+ # Limits
358
+ @property
359
+ def limits(self) -> Dict[str, int]:
360
+ """Numeric limits dictionary."""
361
+ return self._limits
362
+
363
+ @limits.setter
364
+ def limits(self, value: Dict[str, int]):
365
+ self._limits = value if value else {}
366
+
367
+ # Compatible Plan Codes
368
+ @property
369
+ def compatible_plan_codes(self) -> List[str]:
370
+ """List of compatible plan codes."""
371
+ return self._compatible_plan_codes
372
+
373
+ @compatible_plan_codes.setter
374
+ def compatible_plan_codes(self, value: List[str]):
375
+ self._compatible_plan_codes = value if value else []
376
+
377
+ # Incompatible Addon Codes
378
+ @property
379
+ def incompatible_addon_codes(self) -> List[str]:
380
+ """List of mutually exclusive addon codes."""
381
+ return self._incompatible_addon_codes
382
+
383
+ @incompatible_addon_codes.setter
384
+ def incompatible_addon_codes(self, value: List[str]):
385
+ self._incompatible_addon_codes = value if value else []
386
+
387
+ # Feature List
388
+ @property
389
+ def feature_list(self) -> List[str]:
390
+ """Marketing feature list."""
391
+ return self._feature_list
392
+
393
+ @feature_list.setter
394
+ def feature_list(self, value: List[str]):
395
+ self._feature_list = value if value else []
396
+
397
+ # Is Metered
398
+ @property
399
+ def is_metered(self) -> bool:
400
+ """Whether addon requires usage metering."""
401
+ return self._is_metered
402
+
403
+ @is_metered.setter
404
+ def is_metered(self, value: bool):
405
+ self._is_metered = value
406
+
407
+ # Meter Event Name
408
+ @property
409
+ def meter_event_name(self) -> Optional[str]:
410
+ """Event name for metering."""
411
+ return self._meter_event_name
412
+
413
+ @meter_event_name.setter
414
+ def meter_event_name(self, value: Optional[str]):
415
+ self._meter_event_name = value
416
+
417
+ # Billing Scheme
418
+ @property
419
+ def billing_scheme(self) -> str:
420
+ """Billing scheme."""
421
+ return self._billing_scheme
422
+
423
+ @billing_scheme.setter
424
+ def billing_scheme(self, value: str):
425
+ self._billing_scheme = value
426
+
427
+ @property
428
+ def sort_order(self) -> int:
429
+ """Sort order for display."""
430
+ return self._sort_order
431
+
432
+ @sort_order.setter
433
+ def sort_order(self, value: int):
434
+ self._sort_order = value
435
+
436
+ # Helper Methods
437
+
438
+ def is_active(self) -> bool:
439
+ """Check if addon is active."""
440
+ return self._status == self.STATUS_ACTIVE
441
+
442
+ def is_fixed_pricing(self) -> bool:
443
+ """Check if addon uses fixed pricing."""
444
+ return self._pricing_model == self.PRICING_FIXED
445
+
446
+ def is_per_unit_pricing(self) -> bool:
447
+ """Check if addon uses per-unit pricing."""
448
+ return self._pricing_model == self.PRICING_PER_UNIT
449
+
450
+ def is_tiered_pricing(self) -> bool:
451
+ """Check if addon uses tiered pricing."""
452
+ return self._pricing_model == self.PRICING_TIERED
453
+
454
+ def has_trial(self) -> bool:
455
+ """Check if addon has a trial period."""
456
+ return self._trial_days > 0
457
+
458
+ def is_compatible_with_plan(self, plan_code: str) -> bool:
459
+ """Check if addon is compatible with a plan."""
460
+ if not self._compatible_plan_codes:
461
+ return True # Empty list = compatible with all
462
+ return plan_code in self._compatible_plan_codes
463
+
464
+ def is_compatible_with_addon(self, addon_code: str) -> bool:
465
+ """Check if addon is compatible with another addon."""
466
+ return addon_code not in self._incompatible_addon_codes
467
+
468
+ def get_monthly_price_dollars(self) -> float:
469
+ """Get monthly price in dollars (fixed model)."""
470
+ return self._price_monthly_cents / 100.0
471
+
472
+ def get_price_per_unit_dollars(self) -> float:
473
+ """Get per-unit price in dollars."""
474
+ return self._price_per_unit_cents / 100.0
475
+
476
+ def calculate_fixed_price(self, annual: bool = False) -> int:
477
+ """
478
+ Calculate fixed price.
479
+
480
+ Args:
481
+ annual: Whether to use annual pricing
482
+
483
+ Returns:
484
+ Price in cents
485
+ """
486
+ if self._pricing_model != self.PRICING_FIXED:
487
+ return 0
488
+
489
+ if annual and self._price_annual_cents is not None:
490
+ return self._price_annual_cents
491
+
492
+ return self._price_monthly_cents
493
+
494
+ def calculate_per_unit_price(self, units: int) -> int:
495
+ """
496
+ Calculate price for per-unit model.
497
+
498
+ Args:
499
+ units: Number of units to calculate
500
+
501
+ Returns:
502
+ Price in cents
503
+ """
504
+ if self._pricing_model != self.PRICING_PER_UNIT:
505
+ return 0
506
+
507
+ # Apply included units
508
+ billable_units = max(0, units - self._included_units)
509
+
510
+ # Apply min units
511
+ billable_units = max(billable_units, self._min_units)
512
+
513
+ # Apply max units
514
+ if self._max_units is not None:
515
+ billable_units = min(billable_units, self._max_units)
516
+
517
+ return billable_units * self._price_per_unit_cents
518
+
519
+ def calculate_tiered_price(self, units: int) -> int:
520
+ """
521
+ Calculate price using tiered pricing.
522
+
523
+ Args:
524
+ units: Number of units to calculate
525
+
526
+ Returns:
527
+ Price in cents
528
+ """
529
+ if self._pricing_model != self.PRICING_TIERED or not self._pricing_tiers:
530
+ return 0
531
+
532
+ total_price = 0
533
+ remaining_units = units
534
+
535
+ # Sort tiers by 'from' value
536
+ sorted_tiers = sorted(self._pricing_tiers, key=lambda t: t.get('from', 0))
537
+
538
+ for tier in sorted_tiers:
539
+ tier_from = tier.get('from', 0)
540
+ tier_to = tier.get('to', float('inf'))
541
+ tier_price = tier.get('price_cents', 0)
542
+
543
+ if remaining_units <= 0:
544
+ break
545
+
546
+ # Calculate units in this tier
547
+ tier_units = min(remaining_units, tier_to - tier_from + 1)
548
+
549
+ total_price += tier_units * tier_price
550
+ remaining_units -= tier_units
551
+
552
+ return total_price
553
+
554
+ def calculate_price(self, units: int = 1, annual: bool = False) -> int:
555
+ """
556
+ Calculate price based on pricing model.
557
+
558
+ Args:
559
+ units: Number of units (for per_unit/tiered)
560
+ annual: Whether to use annual pricing (for fixed)
561
+
562
+ Returns:
563
+ Price in cents
564
+ """
565
+ if self._pricing_model == self.PRICING_FIXED:
566
+ return self.calculate_fixed_price(annual)
567
+ elif self._pricing_model == self.PRICING_PER_UNIT:
568
+ return self.calculate_per_unit_price(units)
569
+ elif self._pricing_model == self.PRICING_TIERED:
570
+ return self.calculate_tiered_price(units)
571
+
572
+ return 0
573
+
574
+ def validate(self) -> tuple[bool, List[str]]:
575
+ """
576
+ Validate addon data.
577
+
578
+ Returns:
579
+ Tuple of (is_valid, error_messages)
580
+ """
581
+ errors = []
582
+
583
+ if not self._addon_code:
584
+ errors.append("addon_code is required")
585
+
586
+ if not self._addon_name:
587
+ errors.append("addon_name is required")
588
+
589
+ if self._pricing_model == self.PRICING_FIXED and self._price_monthly_cents < 0:
590
+ errors.append("price_monthly_cents cannot be negative")
591
+
592
+ if self._pricing_model == self.PRICING_PER_UNIT:
593
+ if self._price_per_unit_cents < 0:
594
+ errors.append("price_per_unit_cents cannot be negative")
595
+ if not self._unit_name:
596
+ errors.append("unit_name is required for per_unit pricing")
597
+
598
+ if self._pricing_model == self.PRICING_TIERED and not self._pricing_tiers:
599
+ errors.append("pricing_tiers is required for tiered pricing")
600
+
601
+ if self._is_metered and not self._meter_event_name:
602
+ errors.append("meter_event_name is required for metered addons")
603
+
604
+ return (len(errors) == 0, errors)