geek-cafe-saas-sdk 0.7.0__py3-none-any.whl → 0.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of geek-cafe-saas-sdk might be problematic. Click here for more details.

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 +37 -120
  8. geek_cafe_saas_sdk/domains/files/services/file_system_service.py +40 -102
  9. geek_cafe_saas_sdk/domains/files/services/file_version_service.py +44 -124
  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.1.dist-info}/METADATA +1 -1
  77. {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/RECORD +79 -20
  78. {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/WHEEL +0 -0
  79. {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.1.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)