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,492 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Discount model for promotional codes and credits.
|
|
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 Discount(BaseModel):
|
|
15
|
+
"""
|
|
16
|
+
Discount/promo code/credit definition.
|
|
17
|
+
|
|
18
|
+
Represents discounts that can be applied to subscriptions:
|
|
19
|
+
- Promo codes (SUMMER25)
|
|
20
|
+
- Account credits ($100 credit)
|
|
21
|
+
- Referral bonuses
|
|
22
|
+
- Trial extensions
|
|
23
|
+
|
|
24
|
+
Key Features:
|
|
25
|
+
- Percentage or fixed amount
|
|
26
|
+
- Duration limits
|
|
27
|
+
- Usage limits
|
|
28
|
+
- Plan restrictions
|
|
29
|
+
- Expiration dates
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
- 25% off for 3 months (promo code)
|
|
33
|
+
- $100 account credit
|
|
34
|
+
- First month free (trial extension)
|
|
35
|
+
- 20% off annual plans (campaign)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
# Discount type constants
|
|
39
|
+
TYPE_PERCENTAGE = "percentage" # Percentage off
|
|
40
|
+
TYPE_FIXED = "fixed" # Fixed amount off
|
|
41
|
+
TYPE_CREDIT = "credit" # Account credit
|
|
42
|
+
TYPE_TRIAL_EXTENSION = "trial_extension" # Extra trial days
|
|
43
|
+
|
|
44
|
+
# Duration constants
|
|
45
|
+
DURATION_ONCE = "once" # Apply once
|
|
46
|
+
DURATION_REPEATING = "repeating" # Apply for N months
|
|
47
|
+
DURATION_FOREVER = "forever" # Apply indefinitely
|
|
48
|
+
|
|
49
|
+
# Status constants
|
|
50
|
+
STATUS_ACTIVE = "active"
|
|
51
|
+
STATUS_EXPIRED = "expired"
|
|
52
|
+
STATUS_DEPLETED = "depleted" # All uses consumed
|
|
53
|
+
STATUS_ARCHIVED = "archived"
|
|
54
|
+
|
|
55
|
+
def __init__(self):
|
|
56
|
+
super().__init__()
|
|
57
|
+
|
|
58
|
+
# Identification
|
|
59
|
+
self._discount_code: str = "" # e.g., "SUMMER25", "REFERRAL50"
|
|
60
|
+
self._discount_name: str = "" # Display name
|
|
61
|
+
self._description: Optional[str] = None
|
|
62
|
+
|
|
63
|
+
# Type and value
|
|
64
|
+
self._discount_type: str = self.TYPE_PERCENTAGE
|
|
65
|
+
self._amount_off_cents: int = 0 # For fixed type
|
|
66
|
+
self._percent_off: float = 0.0 # For percentage type (e.g., 25.0 for 25%)
|
|
67
|
+
self._trial_extension_days: int = 0 # For trial_extension type
|
|
68
|
+
|
|
69
|
+
# Currency (for fixed discounts)
|
|
70
|
+
self._currency: str = "USD"
|
|
71
|
+
|
|
72
|
+
# Duration
|
|
73
|
+
self._duration: str = self.DURATION_ONCE
|
|
74
|
+
self._duration_in_months: Optional[int] = None # For repeating
|
|
75
|
+
|
|
76
|
+
# Validity period
|
|
77
|
+
self._valid_from_utc_ts: Optional[float] = None
|
|
78
|
+
self._valid_until_utc_ts: Optional[float] = None
|
|
79
|
+
|
|
80
|
+
# Usage limits
|
|
81
|
+
self._max_redemptions: Optional[int] = None # Total uses allowed
|
|
82
|
+
self._redemption_count: int = 0 # Times already used
|
|
83
|
+
self._max_redemptions_per_customer: int = 1 # Uses per customer
|
|
84
|
+
|
|
85
|
+
# Status
|
|
86
|
+
self._status: str = self.STATUS_ACTIVE
|
|
87
|
+
|
|
88
|
+
# Restrictions
|
|
89
|
+
self._minimum_amount_cents: Optional[int] = None # Minimum purchase
|
|
90
|
+
self._applies_to_plan_codes: List[str] = [] # Empty = all plans
|
|
91
|
+
self._applies_to_addon_codes: List[str] = [] # Empty = all addons
|
|
92
|
+
self._applies_to_intervals: List[str] = [] # ["month", "year"]
|
|
93
|
+
|
|
94
|
+
# First-time only
|
|
95
|
+
self._first_time_transaction: bool = False # Only for new customers
|
|
96
|
+
|
|
97
|
+
# Metadata
|
|
98
|
+
self._campaign_name: Optional[str] = None
|
|
99
|
+
self._source: Optional[str] = None # Where discount came from
|
|
100
|
+
self._notes: Optional[str] = None
|
|
101
|
+
|
|
102
|
+
# CRITICAL: Call _setup_indexes() as LAST line in __init__
|
|
103
|
+
self._setup_indexes()
|
|
104
|
+
|
|
105
|
+
def _setup_indexes(self):
|
|
106
|
+
"""Setup DynamoDB indexes for discount queries."""
|
|
107
|
+
|
|
108
|
+
# Primary index: Discount by ID
|
|
109
|
+
primary: DynamoDBIndex = DynamoDBIndex()
|
|
110
|
+
primary.name = "primary"
|
|
111
|
+
primary.partition_key.attribute_name = "pk"
|
|
112
|
+
primary.partition_key.value = lambda: DynamoDBKey.build_key(("discount", self.id))
|
|
113
|
+
primary.sort_key.attribute_name = "sk"
|
|
114
|
+
primary.sort_key.value = lambda: "metadata"
|
|
115
|
+
self.indexes.add_primary(primary)
|
|
116
|
+
|
|
117
|
+
# GSI1: Discounts by status
|
|
118
|
+
gsi: DynamoDBIndex = DynamoDBIndex()
|
|
119
|
+
gsi.name = "gsi1"
|
|
120
|
+
gsi.partition_key.attribute_name = f"{gsi.name}_pk"
|
|
121
|
+
gsi.partition_key.value = lambda: DynamoDBKey.build_key(("status", self.status))
|
|
122
|
+
gsi.sort_key.attribute_name = f"{gsi.name}_sk"
|
|
123
|
+
gsi.sort_key.value = lambda: DynamoDBKey.build_key(("created", self.created_utc_ts))
|
|
124
|
+
self.indexes.add_secondary(gsi)
|
|
125
|
+
|
|
126
|
+
# GSI2: Discounts by discount_code (for code lookup)
|
|
127
|
+
gsi: DynamoDBIndex = DynamoDBIndex()
|
|
128
|
+
gsi.name = "gsi2"
|
|
129
|
+
gsi.partition_key.attribute_name = f"{gsi.name}_pk"
|
|
130
|
+
gsi.partition_key.value = lambda: "DISCOUNT"
|
|
131
|
+
gsi.sort_key.attribute_name = f"{gsi.name}_sk"
|
|
132
|
+
gsi.sort_key.value = lambda: DynamoDBKey.build_key(("code", self.discount_code))
|
|
133
|
+
self.indexes.add_secondary(gsi)
|
|
134
|
+
|
|
135
|
+
# Discount Code
|
|
136
|
+
@property
|
|
137
|
+
def discount_code(self) -> str:
|
|
138
|
+
"""Unique discount code."""
|
|
139
|
+
return self._discount_code
|
|
140
|
+
|
|
141
|
+
@discount_code.setter
|
|
142
|
+
def discount_code(self, value: str):
|
|
143
|
+
if not value:
|
|
144
|
+
raise ValueError("discount_code is required")
|
|
145
|
+
self._discount_code = value.upper().strip()
|
|
146
|
+
|
|
147
|
+
# Discount Name
|
|
148
|
+
@property
|
|
149
|
+
def discount_name(self) -> str:
|
|
150
|
+
"""Display name."""
|
|
151
|
+
return self._discount_name
|
|
152
|
+
|
|
153
|
+
@discount_name.setter
|
|
154
|
+
def discount_name(self, value: str):
|
|
155
|
+
if not value:
|
|
156
|
+
raise ValueError("discount_name is required")
|
|
157
|
+
self._discount_name = value.strip()
|
|
158
|
+
|
|
159
|
+
# Description
|
|
160
|
+
@property
|
|
161
|
+
def description(self) -> Optional[str]:
|
|
162
|
+
"""Detailed description."""
|
|
163
|
+
return self._description
|
|
164
|
+
|
|
165
|
+
@description.setter
|
|
166
|
+
def description(self, value: Optional[str]):
|
|
167
|
+
self._description = value
|
|
168
|
+
|
|
169
|
+
# Discount Type
|
|
170
|
+
@property
|
|
171
|
+
def discount_type(self) -> str:
|
|
172
|
+
"""Discount type."""
|
|
173
|
+
return self._discount_type
|
|
174
|
+
|
|
175
|
+
@discount_type.setter
|
|
176
|
+
def discount_type(self, value: str):
|
|
177
|
+
valid_types = [self.TYPE_PERCENTAGE, self.TYPE_FIXED, self.TYPE_CREDIT, self.TYPE_TRIAL_EXTENSION]
|
|
178
|
+
if value not in valid_types:
|
|
179
|
+
raise ValueError(f"Invalid discount_type: {value}. Must be one of {valid_types}")
|
|
180
|
+
self._discount_type = value
|
|
181
|
+
|
|
182
|
+
# Amount Off Cents
|
|
183
|
+
@property
|
|
184
|
+
def amount_off_cents(self) -> int:
|
|
185
|
+
"""Fixed amount off in cents."""
|
|
186
|
+
return self._amount_off_cents
|
|
187
|
+
|
|
188
|
+
@amount_off_cents.setter
|
|
189
|
+
def amount_off_cents(self, value: int):
|
|
190
|
+
if value < 0:
|
|
191
|
+
raise ValueError("amount_off_cents cannot be negative")
|
|
192
|
+
self._amount_off_cents = value
|
|
193
|
+
|
|
194
|
+
# Percent Off
|
|
195
|
+
@property
|
|
196
|
+
def percent_off(self) -> float:
|
|
197
|
+
"""Percentage off."""
|
|
198
|
+
return self._percent_off
|
|
199
|
+
|
|
200
|
+
@percent_off.setter
|
|
201
|
+
def percent_off(self, value: float):
|
|
202
|
+
if value < 0 or value > 100:
|
|
203
|
+
raise ValueError("percent_off must be between 0 and 100")
|
|
204
|
+
self._percent_off = value
|
|
205
|
+
|
|
206
|
+
# Trial Extension Days
|
|
207
|
+
@property
|
|
208
|
+
def trial_extension_days(self) -> int:
|
|
209
|
+
"""Trial extension days."""
|
|
210
|
+
return self._trial_extension_days
|
|
211
|
+
|
|
212
|
+
@trial_extension_days.setter
|
|
213
|
+
def trial_extension_days(self, value: int):
|
|
214
|
+
if value < 0:
|
|
215
|
+
raise ValueError("trial_extension_days cannot be negative")
|
|
216
|
+
self._trial_extension_days = value
|
|
217
|
+
|
|
218
|
+
# Currency
|
|
219
|
+
@property
|
|
220
|
+
def currency(self) -> str:
|
|
221
|
+
"""Currency code."""
|
|
222
|
+
return self._currency
|
|
223
|
+
|
|
224
|
+
@currency.setter
|
|
225
|
+
def currency(self, value: str):
|
|
226
|
+
self._currency = value.upper() if value else "USD"
|
|
227
|
+
|
|
228
|
+
# Duration
|
|
229
|
+
@property
|
|
230
|
+
def duration(self) -> str:
|
|
231
|
+
"""Duration type."""
|
|
232
|
+
return self._duration
|
|
233
|
+
|
|
234
|
+
@duration.setter
|
|
235
|
+
def duration(self, value: str):
|
|
236
|
+
valid_durations = [self.DURATION_ONCE, self.DURATION_REPEATING, self.DURATION_FOREVER]
|
|
237
|
+
if value not in valid_durations:
|
|
238
|
+
raise ValueError(f"Invalid duration: {value}. Must be one of {valid_durations}")
|
|
239
|
+
self._duration = value
|
|
240
|
+
|
|
241
|
+
# Duration In Months
|
|
242
|
+
@property
|
|
243
|
+
def duration_in_months(self) -> Optional[int]:
|
|
244
|
+
"""Duration in months (for repeating)."""
|
|
245
|
+
return self._duration_in_months
|
|
246
|
+
|
|
247
|
+
@duration_in_months.setter
|
|
248
|
+
def duration_in_months(self, value: Optional[int]):
|
|
249
|
+
if value is not None and value < 1:
|
|
250
|
+
raise ValueError("duration_in_months must be at least 1")
|
|
251
|
+
self._duration_in_months = value
|
|
252
|
+
|
|
253
|
+
# Valid From
|
|
254
|
+
@property
|
|
255
|
+
def valid_from_utc_ts(self) -> Optional[float]:
|
|
256
|
+
"""Valid from timestamp."""
|
|
257
|
+
return self._valid_from_utc_ts
|
|
258
|
+
|
|
259
|
+
@valid_from_utc_ts.setter
|
|
260
|
+
def valid_from_utc_ts(self, value: Optional[float]):
|
|
261
|
+
self._valid_from_utc_ts = value
|
|
262
|
+
|
|
263
|
+
# Valid Until
|
|
264
|
+
@property
|
|
265
|
+
def valid_until_utc_ts(self) -> Optional[float]:
|
|
266
|
+
"""Valid until timestamp."""
|
|
267
|
+
return self._valid_until_utc_ts
|
|
268
|
+
|
|
269
|
+
@valid_until_utc_ts.setter
|
|
270
|
+
def valid_until_utc_ts(self, value: Optional[float]):
|
|
271
|
+
self._valid_until_utc_ts = value
|
|
272
|
+
|
|
273
|
+
# Max Redemptions
|
|
274
|
+
@property
|
|
275
|
+
def max_redemptions(self) -> Optional[int]:
|
|
276
|
+
"""Maximum total redemptions."""
|
|
277
|
+
return self._max_redemptions
|
|
278
|
+
|
|
279
|
+
@max_redemptions.setter
|
|
280
|
+
def max_redemptions(self, value: Optional[int]):
|
|
281
|
+
if value is not None and value < 1:
|
|
282
|
+
raise ValueError("max_redemptions must be at least 1")
|
|
283
|
+
self._max_redemptions = value
|
|
284
|
+
|
|
285
|
+
# Redemption Count
|
|
286
|
+
@property
|
|
287
|
+
def redemption_count(self) -> int:
|
|
288
|
+
"""Current redemption count."""
|
|
289
|
+
return self._redemption_count
|
|
290
|
+
|
|
291
|
+
@redemption_count.setter
|
|
292
|
+
def redemption_count(self, value: int):
|
|
293
|
+
if value < 0:
|
|
294
|
+
raise ValueError("redemption_count cannot be negative")
|
|
295
|
+
self._redemption_count = value
|
|
296
|
+
|
|
297
|
+
# Max Redemptions Per Customer
|
|
298
|
+
@property
|
|
299
|
+
def max_redemptions_per_customer(self) -> int:
|
|
300
|
+
"""Max redemptions per customer."""
|
|
301
|
+
return self._max_redemptions_per_customer
|
|
302
|
+
|
|
303
|
+
@max_redemptions_per_customer.setter
|
|
304
|
+
def max_redemptions_per_customer(self, value: int):
|
|
305
|
+
if value < 1:
|
|
306
|
+
raise ValueError("max_redemptions_per_customer must be at least 1")
|
|
307
|
+
self._max_redemptions_per_customer = value
|
|
308
|
+
|
|
309
|
+
# Status
|
|
310
|
+
@property
|
|
311
|
+
def status(self) -> str:
|
|
312
|
+
"""Discount status."""
|
|
313
|
+
return self._status
|
|
314
|
+
|
|
315
|
+
@status.setter
|
|
316
|
+
def status(self, value: str):
|
|
317
|
+
valid_statuses = [self.STATUS_ACTIVE, self.STATUS_EXPIRED, self.STATUS_DEPLETED, self.STATUS_ARCHIVED]
|
|
318
|
+
if value not in valid_statuses:
|
|
319
|
+
raise ValueError(f"Invalid status: {value}. Must be one of {valid_statuses}")
|
|
320
|
+
self._status = value
|
|
321
|
+
|
|
322
|
+
# Minimum Amount Cents
|
|
323
|
+
@property
|
|
324
|
+
def minimum_amount_cents(self) -> Optional[int]:
|
|
325
|
+
"""Minimum purchase amount."""
|
|
326
|
+
return self._minimum_amount_cents
|
|
327
|
+
|
|
328
|
+
@minimum_amount_cents.setter
|
|
329
|
+
def minimum_amount_cents(self, value: Optional[int]):
|
|
330
|
+
if value is not None and value < 0:
|
|
331
|
+
raise ValueError("minimum_amount_cents cannot be negative")
|
|
332
|
+
self._minimum_amount_cents = value
|
|
333
|
+
|
|
334
|
+
# Applies To Plan Codes
|
|
335
|
+
@property
|
|
336
|
+
def applies_to_plan_codes(self) -> List[str]:
|
|
337
|
+
"""Applicable plan codes."""
|
|
338
|
+
return self._applies_to_plan_codes
|
|
339
|
+
|
|
340
|
+
@applies_to_plan_codes.setter
|
|
341
|
+
def applies_to_plan_codes(self, value: List[str]):
|
|
342
|
+
self._applies_to_plan_codes = value if value else []
|
|
343
|
+
|
|
344
|
+
# Applies To Addon Codes
|
|
345
|
+
@property
|
|
346
|
+
def applies_to_addon_codes(self) -> List[str]:
|
|
347
|
+
"""Applicable addon codes."""
|
|
348
|
+
return self._applies_to_addon_codes
|
|
349
|
+
|
|
350
|
+
@applies_to_addon_codes.setter
|
|
351
|
+
def applies_to_addon_codes(self, value: List[str]):
|
|
352
|
+
self._applies_to_addon_codes = value if value else []
|
|
353
|
+
|
|
354
|
+
# Applies To Intervals
|
|
355
|
+
@property
|
|
356
|
+
def applies_to_intervals(self) -> List[str]:
|
|
357
|
+
"""Applicable billing intervals."""
|
|
358
|
+
return self._applies_to_intervals
|
|
359
|
+
|
|
360
|
+
@applies_to_intervals.setter
|
|
361
|
+
def applies_to_intervals(self, value: List[str]):
|
|
362
|
+
self._applies_to_intervals = value if value else []
|
|
363
|
+
|
|
364
|
+
# First Time Transaction
|
|
365
|
+
@property
|
|
366
|
+
def first_time_transaction(self) -> bool:
|
|
367
|
+
"""Whether discount is for first-time customers only."""
|
|
368
|
+
return self._first_time_transaction
|
|
369
|
+
|
|
370
|
+
@first_time_transaction.setter
|
|
371
|
+
def first_time_transaction(self, value: bool):
|
|
372
|
+
self._first_time_transaction = value
|
|
373
|
+
|
|
374
|
+
# Helper Methods
|
|
375
|
+
|
|
376
|
+
def is_active(self) -> bool:
|
|
377
|
+
"""Check if discount is active."""
|
|
378
|
+
return self._status == self.STATUS_ACTIVE
|
|
379
|
+
|
|
380
|
+
def is_valid_now(self) -> bool:
|
|
381
|
+
"""Check if discount is currently valid."""
|
|
382
|
+
now = dt.datetime.now(dt.UTC).timestamp()
|
|
383
|
+
|
|
384
|
+
if self._valid_from_utc_ts and now < self._valid_from_utc_ts:
|
|
385
|
+
return False
|
|
386
|
+
|
|
387
|
+
if self._valid_until_utc_ts and now > self._valid_until_utc_ts:
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
return True
|
|
391
|
+
|
|
392
|
+
def is_depleted(self) -> bool:
|
|
393
|
+
"""Check if all redemptions are used."""
|
|
394
|
+
if self._max_redemptions is None:
|
|
395
|
+
return False
|
|
396
|
+
return self._redemption_count >= self._max_redemptions
|
|
397
|
+
|
|
398
|
+
def can_be_redeemed(self) -> bool:
|
|
399
|
+
"""Check if discount can currently be redeemed."""
|
|
400
|
+
return (self.is_active() and
|
|
401
|
+
self.is_valid_now() and
|
|
402
|
+
not self.is_depleted())
|
|
403
|
+
|
|
404
|
+
def applies_to_plan(self, plan_code: str) -> bool:
|
|
405
|
+
"""Check if discount applies to a plan."""
|
|
406
|
+
if not self._applies_to_plan_codes:
|
|
407
|
+
return True # Empty = applies to all
|
|
408
|
+
return plan_code in self._applies_to_plan_codes
|
|
409
|
+
|
|
410
|
+
def applies_to_addon(self, addon_code: str) -> bool:
|
|
411
|
+
"""Check if discount applies to an addon."""
|
|
412
|
+
if not self._applies_to_addon_codes:
|
|
413
|
+
return True # Empty = applies to all
|
|
414
|
+
return addon_code in self._applies_to_addon_codes
|
|
415
|
+
|
|
416
|
+
def applies_to_interval(self, interval: str) -> bool:
|
|
417
|
+
"""Check if discount applies to billing interval."""
|
|
418
|
+
if not self._applies_to_intervals:
|
|
419
|
+
return True # Empty = applies to all
|
|
420
|
+
return interval in self._applies_to_intervals
|
|
421
|
+
|
|
422
|
+
def calculate_discount(self, price_cents: int) -> int:
|
|
423
|
+
"""
|
|
424
|
+
Calculate discount amount for a given price.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
price_cents: Original price in cents
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
Discount amount in cents
|
|
431
|
+
"""
|
|
432
|
+
if self._discount_type == self.TYPE_PERCENTAGE:
|
|
433
|
+
return int(price_cents * (self._percent_off / 100.0))
|
|
434
|
+
|
|
435
|
+
elif self._discount_type == self.TYPE_FIXED:
|
|
436
|
+
return min(self._amount_off_cents, price_cents)
|
|
437
|
+
|
|
438
|
+
elif self._discount_type == self.TYPE_CREDIT:
|
|
439
|
+
return min(self._amount_off_cents, price_cents)
|
|
440
|
+
|
|
441
|
+
return 0
|
|
442
|
+
|
|
443
|
+
def increment_redemption_count(self):
|
|
444
|
+
"""Increment redemption counter."""
|
|
445
|
+
self._redemption_count += 1
|
|
446
|
+
|
|
447
|
+
# Auto-update status if depleted
|
|
448
|
+
if self.is_depleted():
|
|
449
|
+
self._status = self.STATUS_DEPLETED
|
|
450
|
+
|
|
451
|
+
def get_discount_display(self) -> str:
|
|
452
|
+
"""Get formatted discount for display."""
|
|
453
|
+
if self._discount_type == self.TYPE_PERCENTAGE:
|
|
454
|
+
return f"{self._percent_off:.0f}% off"
|
|
455
|
+
elif self._discount_type == self.TYPE_FIXED:
|
|
456
|
+
dollars = self._amount_off_cents / 100.0
|
|
457
|
+
return f"${dollars:.2f} off"
|
|
458
|
+
elif self._discount_type == self.TYPE_CREDIT:
|
|
459
|
+
dollars = self._amount_off_cents / 100.0
|
|
460
|
+
return f"${dollars:.2f} credit"
|
|
461
|
+
elif self._discount_type == self.TYPE_TRIAL_EXTENSION:
|
|
462
|
+
return f"{self._trial_extension_days} day trial extension"
|
|
463
|
+
return "Discount"
|
|
464
|
+
|
|
465
|
+
def validate(self) -> tuple[bool, List[str]]:
|
|
466
|
+
"""
|
|
467
|
+
Validate discount data.
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
Tuple of (is_valid, error_messages)
|
|
471
|
+
"""
|
|
472
|
+
errors = []
|
|
473
|
+
|
|
474
|
+
if not self._discount_code:
|
|
475
|
+
errors.append("discount_code is required")
|
|
476
|
+
|
|
477
|
+
if not self._discount_name:
|
|
478
|
+
errors.append("discount_name is required")
|
|
479
|
+
|
|
480
|
+
if self._discount_type == self.TYPE_PERCENTAGE and (self._percent_off <= 0 or self._percent_off > 100):
|
|
481
|
+
errors.append("percent_off must be between 0 and 100")
|
|
482
|
+
|
|
483
|
+
if self._discount_type in [self.TYPE_FIXED, self.TYPE_CREDIT] and self._amount_off_cents <= 0:
|
|
484
|
+
errors.append("amount_off_cents must be greater than 0")
|
|
485
|
+
|
|
486
|
+
if self._discount_type == self.TYPE_TRIAL_EXTENSION and self._trial_extension_days <= 0:
|
|
487
|
+
errors.append("trial_extension_days must be greater than 0")
|
|
488
|
+
|
|
489
|
+
if self._duration == self.DURATION_REPEATING and not self._duration_in_months:
|
|
490
|
+
errors.append("duration_in_months is required for repeating duration")
|
|
491
|
+
|
|
492
|
+
return (len(errors) == 0, errors)
|