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.
- 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 +60 -136
- geek_cafe_saas_sdk/domains/files/services/file_system_service.py +43 -104
- geek_cafe_saas_sdk/domains/files/services/file_version_service.py +57 -131
- 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.2.dist-info}/METADATA +1 -1
- {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.2.dist-info}/RECORD +79 -20
- {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.2.dist-info}/WHEEL +0 -0
- {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)
|