payplus-python 0.1.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.
- examples/basic_payment.py +29 -0
- examples/fastapi_webhooks.py +130 -0
- examples/subscription_saas.py +206 -0
- payplus/__init__.py +30 -0
- payplus/api/__init__.py +15 -0
- payplus/api/base.py +37 -0
- payplus/api/payment_pages.py +176 -0
- payplus/api/payments.py +117 -0
- payplus/api/recurring.py +216 -0
- payplus/api/transactions.py +203 -0
- payplus/client.py +211 -0
- payplus/exceptions.py +57 -0
- payplus/models/__init__.py +23 -0
- payplus/models/customer.py +136 -0
- payplus/models/invoice.py +242 -0
- payplus/models/payment.py +179 -0
- payplus/models/subscription.py +193 -0
- payplus/models/tier.py +226 -0
- payplus/subscriptions/__init__.py +11 -0
- payplus/subscriptions/billing.py +231 -0
- payplus/subscriptions/manager.py +600 -0
- payplus/subscriptions/storage.py +571 -0
- payplus/webhooks/__init__.py +10 -0
- payplus/webhooks/handler.py +370 -0
- payplus_python-0.1.2.dist-info/METADATA +446 -0
- payplus_python-0.1.2.dist-info/RECORD +31 -0
- payplus_python-0.1.2.dist-info/WHEEL +5 -0
- payplus_python-0.1.2.dist-info/licenses/LICENSE +21 -0
- payplus_python-0.1.2.dist-info/top_level.txt +3 -0
- tests/__init__.py +1 -0
- tests/test_models.py +348 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Payment model for tracking payments.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Optional, Any
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PaymentStatus(str, Enum):
|
|
16
|
+
"""Payment status."""
|
|
17
|
+
PENDING = "pending"
|
|
18
|
+
PROCESSING = "processing"
|
|
19
|
+
SUCCEEDED = "succeeded"
|
|
20
|
+
FAILED = "failed"
|
|
21
|
+
CANCELED = "canceled"
|
|
22
|
+
REFUNDED = "refunded"
|
|
23
|
+
PARTIALLY_REFUNDED = "partially_refunded"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PaymentMethod(str, Enum):
|
|
27
|
+
"""Payment method type."""
|
|
28
|
+
CARD = "card"
|
|
29
|
+
BANK_TRANSFER = "bank_transfer"
|
|
30
|
+
DIRECT_DEBIT = "direct_debit"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RefundReason(str, Enum):
|
|
34
|
+
"""Refund reason codes."""
|
|
35
|
+
REQUESTED_BY_CUSTOMER = "requested_by_customer"
|
|
36
|
+
DUPLICATE = "duplicate"
|
|
37
|
+
FRAUDULENT = "fraudulent"
|
|
38
|
+
SERVICE_NOT_PROVIDED = "service_not_provided"
|
|
39
|
+
OTHER = "other"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Refund(BaseModel):
|
|
43
|
+
"""Refund record."""
|
|
44
|
+
|
|
45
|
+
id: str
|
|
46
|
+
amount: Decimal
|
|
47
|
+
currency: str = "ILS"
|
|
48
|
+
reason: Optional[RefundReason] = None
|
|
49
|
+
status: str = "succeeded"
|
|
50
|
+
payplus_refund_uid: Optional[str] = None
|
|
51
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
52
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Payment(BaseModel):
|
|
56
|
+
"""
|
|
57
|
+
Payment model for tracking individual payments.
|
|
58
|
+
|
|
59
|
+
This tracks both successful and failed payment attempts,
|
|
60
|
+
linked to subscriptions and invoices.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
id: str = Field(..., description="Unique payment ID")
|
|
64
|
+
customer_id: str = Field(..., description="Customer ID")
|
|
65
|
+
subscription_id: Optional[str] = Field(None, description="Subscription ID if recurring")
|
|
66
|
+
invoice_id: Optional[str] = Field(None, description="Invoice ID")
|
|
67
|
+
|
|
68
|
+
# PayPlus integration
|
|
69
|
+
payplus_transaction_uid: Optional[str] = Field(None, description="PayPlus transaction UID")
|
|
70
|
+
payplus_approval_number: Optional[str] = Field(None, description="Credit card approval number")
|
|
71
|
+
|
|
72
|
+
# Amount
|
|
73
|
+
amount: Decimal = Field(..., description="Payment amount")
|
|
74
|
+
currency: str = Field(default="ILS", description="Currency code")
|
|
75
|
+
|
|
76
|
+
# Payment method
|
|
77
|
+
payment_method_type: PaymentMethod = Field(default=PaymentMethod.CARD)
|
|
78
|
+
payment_method_id: Optional[str] = Field(None, description="Payment method ID used")
|
|
79
|
+
card_last_four: Optional[str] = Field(None, description="Last 4 digits of card")
|
|
80
|
+
card_brand: Optional[str] = Field(None, description="Card brand")
|
|
81
|
+
|
|
82
|
+
# Status
|
|
83
|
+
status: PaymentStatus = Field(default=PaymentStatus.PENDING)
|
|
84
|
+
failure_code: Optional[str] = Field(None, description="Failure code if failed")
|
|
85
|
+
failure_message: Optional[str] = Field(None, description="Failure message")
|
|
86
|
+
|
|
87
|
+
# Refunds
|
|
88
|
+
refunds: list[Refund] = Field(default_factory=list)
|
|
89
|
+
amount_refunded: Decimal = Field(default=Decimal("0"))
|
|
90
|
+
|
|
91
|
+
# Installments
|
|
92
|
+
installments: int = Field(default=1, description="Number of installments")
|
|
93
|
+
installment_number: Optional[int] = Field(None, description="Current installment number")
|
|
94
|
+
|
|
95
|
+
# Description
|
|
96
|
+
description: Optional[str] = None
|
|
97
|
+
statement_descriptor: Optional[str] = None
|
|
98
|
+
|
|
99
|
+
# Receipt
|
|
100
|
+
receipt_email: Optional[str] = None
|
|
101
|
+
receipt_url: Optional[str] = None
|
|
102
|
+
|
|
103
|
+
# Metadata
|
|
104
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
105
|
+
|
|
106
|
+
# Timestamps
|
|
107
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
108
|
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
|
109
|
+
paid_at: Optional[datetime] = None
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def is_successful(self) -> bool:
|
|
113
|
+
"""Check if payment was successful."""
|
|
114
|
+
return self.status == PaymentStatus.SUCCEEDED
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def is_refunded(self) -> bool:
|
|
118
|
+
"""Check if payment was fully refunded."""
|
|
119
|
+
return self.status == PaymentStatus.REFUNDED
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def net_amount(self) -> Decimal:
|
|
123
|
+
"""Get net amount after refunds."""
|
|
124
|
+
return self.amount - self.amount_refunded
|
|
125
|
+
|
|
126
|
+
def add_refund(
|
|
127
|
+
self,
|
|
128
|
+
refund_id: str,
|
|
129
|
+
amount: Decimal,
|
|
130
|
+
reason: Optional[RefundReason] = None,
|
|
131
|
+
payplus_refund_uid: Optional[str] = None,
|
|
132
|
+
) -> Refund:
|
|
133
|
+
"""Add a refund to this payment."""
|
|
134
|
+
refund = Refund(
|
|
135
|
+
id=refund_id,
|
|
136
|
+
amount=amount,
|
|
137
|
+
currency=self.currency,
|
|
138
|
+
reason=reason,
|
|
139
|
+
payplus_refund_uid=payplus_refund_uid,
|
|
140
|
+
)
|
|
141
|
+
self.refunds.append(refund)
|
|
142
|
+
self.amount_refunded += amount
|
|
143
|
+
self.updated_at = datetime.utcnow()
|
|
144
|
+
|
|
145
|
+
if self.amount_refunded >= self.amount:
|
|
146
|
+
self.status = PaymentStatus.REFUNDED
|
|
147
|
+
else:
|
|
148
|
+
self.status = PaymentStatus.PARTIALLY_REFUNDED
|
|
149
|
+
|
|
150
|
+
return refund
|
|
151
|
+
|
|
152
|
+
def mark_succeeded(
|
|
153
|
+
self,
|
|
154
|
+
transaction_uid: Optional[str] = None,
|
|
155
|
+
approval_number: Optional[str] = None,
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Mark payment as succeeded."""
|
|
158
|
+
self.status = PaymentStatus.SUCCEEDED
|
|
159
|
+
self.paid_at = datetime.utcnow()
|
|
160
|
+
self.updated_at = datetime.utcnow()
|
|
161
|
+
|
|
162
|
+
if transaction_uid:
|
|
163
|
+
self.payplus_transaction_uid = transaction_uid
|
|
164
|
+
if approval_number:
|
|
165
|
+
self.payplus_approval_number = approval_number
|
|
166
|
+
|
|
167
|
+
def mark_failed(
|
|
168
|
+
self,
|
|
169
|
+
failure_code: Optional[str] = None,
|
|
170
|
+
failure_message: Optional[str] = None,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Mark payment as failed."""
|
|
173
|
+
self.status = PaymentStatus.FAILED
|
|
174
|
+
self.failure_code = failure_code
|
|
175
|
+
self.failure_message = failure_message
|
|
176
|
+
self.updated_at = datetime.utcnow()
|
|
177
|
+
|
|
178
|
+
class Config:
|
|
179
|
+
use_enum_values = True
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Subscription model for SaaS subscription management.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from datetime import datetime, date
|
|
8
|
+
from typing import Optional, Any
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SubscriptionStatus(str, Enum):
|
|
16
|
+
"""Subscription status."""
|
|
17
|
+
ACTIVE = "active"
|
|
18
|
+
TRIALING = "trialing"
|
|
19
|
+
PAST_DUE = "past_due"
|
|
20
|
+
PAUSED = "paused"
|
|
21
|
+
CANCELED = "canceled"
|
|
22
|
+
UNPAID = "unpaid"
|
|
23
|
+
INCOMPLETE = "incomplete"
|
|
24
|
+
INCOMPLETE_EXPIRED = "incomplete_expired"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BillingCycle(str, Enum):
|
|
28
|
+
"""Billing cycle interval."""
|
|
29
|
+
DAILY = "daily"
|
|
30
|
+
WEEKLY = "weekly"
|
|
31
|
+
MONTHLY = "monthly"
|
|
32
|
+
QUARTERLY = "quarterly"
|
|
33
|
+
YEARLY = "yearly"
|
|
34
|
+
|
|
35
|
+
def to_interval(self) -> tuple[str, int]:
|
|
36
|
+
"""Convert to interval and count."""
|
|
37
|
+
mapping = {
|
|
38
|
+
BillingCycle.DAILY: ("day", 1),
|
|
39
|
+
BillingCycle.WEEKLY: ("week", 1),
|
|
40
|
+
BillingCycle.MONTHLY: ("month", 1),
|
|
41
|
+
BillingCycle.QUARTERLY: ("month", 3),
|
|
42
|
+
BillingCycle.YEARLY: ("year", 1),
|
|
43
|
+
}
|
|
44
|
+
return mapping[self]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SubscriptionItem(BaseModel):
|
|
48
|
+
"""Item in a subscription (for metered billing)."""
|
|
49
|
+
|
|
50
|
+
id: str
|
|
51
|
+
tier_id: str
|
|
52
|
+
quantity: int = 1
|
|
53
|
+
unit_amount: Decimal = Decimal("0")
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def total_amount(self) -> Decimal:
|
|
57
|
+
return self.unit_amount * self.quantity
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Subscription(BaseModel):
|
|
61
|
+
"""
|
|
62
|
+
Subscription model for managing SaaS subscriptions.
|
|
63
|
+
|
|
64
|
+
This model tracks the subscription lifecycle, billing, and links
|
|
65
|
+
to the customer and tier.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
id: str = Field(..., description="Unique subscription ID")
|
|
69
|
+
customer_id: str = Field(..., description="Customer ID")
|
|
70
|
+
tier_id: str = Field(..., description="Pricing tier ID")
|
|
71
|
+
|
|
72
|
+
# PayPlus integration
|
|
73
|
+
payplus_recurring_uid: Optional[str] = Field(None, description="PayPlus recurring payment UID")
|
|
74
|
+
payment_method_id: Optional[str] = Field(None, description="Payment method ID used")
|
|
75
|
+
|
|
76
|
+
# Status
|
|
77
|
+
status: SubscriptionStatus = Field(default=SubscriptionStatus.INCOMPLETE)
|
|
78
|
+
|
|
79
|
+
# Pricing
|
|
80
|
+
amount: Decimal = Field(..., description="Subscription amount per period")
|
|
81
|
+
currency: str = Field(default="ILS", description="Currency code")
|
|
82
|
+
|
|
83
|
+
# Billing
|
|
84
|
+
billing_cycle: BillingCycle = Field(default=BillingCycle.MONTHLY)
|
|
85
|
+
billing_anchor: Optional[int] = Field(None, description="Day of month for billing (1-28)")
|
|
86
|
+
|
|
87
|
+
# Items (for metered/quantity-based billing)
|
|
88
|
+
items: list[SubscriptionItem] = Field(default_factory=list)
|
|
89
|
+
|
|
90
|
+
# Trial
|
|
91
|
+
trial_start: Optional[datetime] = None
|
|
92
|
+
trial_end: Optional[datetime] = None
|
|
93
|
+
|
|
94
|
+
# Period tracking
|
|
95
|
+
current_period_start: datetime = Field(default_factory=datetime.utcnow)
|
|
96
|
+
current_period_end: Optional[datetime] = None
|
|
97
|
+
|
|
98
|
+
# Cancellation
|
|
99
|
+
cancel_at_period_end: bool = Field(default=False)
|
|
100
|
+
canceled_at: Optional[datetime] = None
|
|
101
|
+
ended_at: Optional[datetime] = None
|
|
102
|
+
cancellation_reason: Optional[str] = None
|
|
103
|
+
|
|
104
|
+
# Pause
|
|
105
|
+
pause_collection: Optional[dict[str, Any]] = None
|
|
106
|
+
paused_at: Optional[datetime] = None
|
|
107
|
+
|
|
108
|
+
# Counters
|
|
109
|
+
invoice_count: int = Field(default=0)
|
|
110
|
+
failed_payment_count: int = Field(default=0)
|
|
111
|
+
|
|
112
|
+
# Metadata
|
|
113
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
114
|
+
|
|
115
|
+
# Timestamps
|
|
116
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
117
|
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def is_active(self) -> bool:
|
|
121
|
+
"""Check if subscription is currently active."""
|
|
122
|
+
return self.status in (SubscriptionStatus.ACTIVE, SubscriptionStatus.TRIALING)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def is_trialing(self) -> bool:
|
|
126
|
+
"""Check if subscription is in trial period."""
|
|
127
|
+
return self.status == SubscriptionStatus.TRIALING
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def is_canceled(self) -> bool:
|
|
131
|
+
"""Check if subscription is canceled."""
|
|
132
|
+
return self.status == SubscriptionStatus.CANCELED or self.cancel_at_period_end
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def will_renew(self) -> bool:
|
|
136
|
+
"""Check if subscription will renew at period end."""
|
|
137
|
+
return self.is_active and not self.cancel_at_period_end
|
|
138
|
+
|
|
139
|
+
def cancel(self, at_period_end: bool = True, reason: Optional[str] = None) -> None:
|
|
140
|
+
"""Cancel the subscription."""
|
|
141
|
+
self.cancel_at_period_end = at_period_end
|
|
142
|
+
self.cancellation_reason = reason
|
|
143
|
+
self.canceled_at = datetime.utcnow()
|
|
144
|
+
self.updated_at = datetime.utcnow()
|
|
145
|
+
|
|
146
|
+
if not at_period_end:
|
|
147
|
+
self.status = SubscriptionStatus.CANCELED
|
|
148
|
+
self.ended_at = datetime.utcnow()
|
|
149
|
+
|
|
150
|
+
def pause(self, resume_at: Optional[datetime] = None) -> None:
|
|
151
|
+
"""Pause the subscription."""
|
|
152
|
+
self.status = SubscriptionStatus.PAUSED
|
|
153
|
+
self.paused_at = datetime.utcnow()
|
|
154
|
+
self.pause_collection = {
|
|
155
|
+
"paused_at": self.paused_at.isoformat(),
|
|
156
|
+
"resume_at": resume_at.isoformat() if resume_at else None,
|
|
157
|
+
}
|
|
158
|
+
self.updated_at = datetime.utcnow()
|
|
159
|
+
|
|
160
|
+
def resume(self) -> None:
|
|
161
|
+
"""Resume a paused subscription."""
|
|
162
|
+
if self.status == SubscriptionStatus.PAUSED:
|
|
163
|
+
self.status = SubscriptionStatus.ACTIVE
|
|
164
|
+
self.paused_at = None
|
|
165
|
+
self.pause_collection = None
|
|
166
|
+
self.updated_at = datetime.utcnow()
|
|
167
|
+
|
|
168
|
+
def mark_payment_failed(self) -> None:
|
|
169
|
+
"""Mark a payment as failed."""
|
|
170
|
+
self.failed_payment_count += 1
|
|
171
|
+
self.updated_at = datetime.utcnow()
|
|
172
|
+
|
|
173
|
+
# Update status based on failure count
|
|
174
|
+
if self.failed_payment_count >= 4:
|
|
175
|
+
self.status = SubscriptionStatus.UNPAID
|
|
176
|
+
else:
|
|
177
|
+
self.status = SubscriptionStatus.PAST_DUE
|
|
178
|
+
|
|
179
|
+
def mark_payment_succeeded(self) -> None:
|
|
180
|
+
"""Mark a payment as succeeded."""
|
|
181
|
+
self.failed_payment_count = 0
|
|
182
|
+
self.status = SubscriptionStatus.ACTIVE
|
|
183
|
+
self.invoice_count += 1
|
|
184
|
+
self.updated_at = datetime.utcnow()
|
|
185
|
+
|
|
186
|
+
def change_tier(self, new_tier_id: str, new_amount: Decimal) -> None:
|
|
187
|
+
"""Change subscription tier."""
|
|
188
|
+
self.tier_id = new_tier_id
|
|
189
|
+
self.amount = new_amount
|
|
190
|
+
self.updated_at = datetime.utcnow()
|
|
191
|
+
|
|
192
|
+
class Config:
|
|
193
|
+
use_enum_values = True
|
payplus/models/tier.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tier model for pricing plans.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Optional, Any
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
from payplus.models.subscription import BillingCycle
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TierType(str, Enum):
|
|
18
|
+
"""Tier pricing type."""
|
|
19
|
+
FLAT = "flat"
|
|
20
|
+
PER_UNIT = "per_unit"
|
|
21
|
+
TIERED = "tiered"
|
|
22
|
+
VOLUME = "volume"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UsageType(str, Enum):
|
|
26
|
+
"""Usage tracking type."""
|
|
27
|
+
LICENSED = "licensed"
|
|
28
|
+
METERED = "metered"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TierFeature(BaseModel):
|
|
32
|
+
"""Feature included in a tier."""
|
|
33
|
+
|
|
34
|
+
id: str = Field(..., description="Feature ID")
|
|
35
|
+
name: str = Field(..., description="Feature name")
|
|
36
|
+
description: Optional[str] = None
|
|
37
|
+
|
|
38
|
+
# Limits
|
|
39
|
+
included_quantity: Optional[int] = Field(None, description="Included quantity (None = unlimited)")
|
|
40
|
+
limit: Optional[int] = Field(None, description="Hard limit (None = unlimited)")
|
|
41
|
+
|
|
42
|
+
# Overage
|
|
43
|
+
overage_price: Optional[Decimal] = Field(None, description="Price per unit over included")
|
|
44
|
+
|
|
45
|
+
# Metadata
|
|
46
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Tier(BaseModel):
|
|
50
|
+
"""
|
|
51
|
+
Pricing tier model for SaaS subscriptions.
|
|
52
|
+
|
|
53
|
+
Define different tiers (Free, Basic, Pro, Enterprise) with
|
|
54
|
+
features, limits, and pricing.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
id: str = Field(..., description="Unique tier ID")
|
|
58
|
+
name: str = Field(..., description="Tier display name")
|
|
59
|
+
description: Optional[str] = None
|
|
60
|
+
|
|
61
|
+
# Pricing
|
|
62
|
+
price: Decimal = Field(..., description="Price per billing cycle")
|
|
63
|
+
currency: str = Field(default="ILS")
|
|
64
|
+
billing_cycle: BillingCycle = Field(default=BillingCycle.MONTHLY)
|
|
65
|
+
|
|
66
|
+
# Pricing type
|
|
67
|
+
tier_type: TierType = Field(default=TierType.FLAT)
|
|
68
|
+
usage_type: UsageType = Field(default=UsageType.LICENSED)
|
|
69
|
+
|
|
70
|
+
# Per-unit pricing
|
|
71
|
+
unit_amount: Optional[Decimal] = Field(None, description="Price per unit for per_unit pricing")
|
|
72
|
+
minimum_units: int = Field(default=1)
|
|
73
|
+
maximum_units: Optional[int] = None
|
|
74
|
+
|
|
75
|
+
# Trial
|
|
76
|
+
trial_days: int = Field(default=0, description="Trial period in days")
|
|
77
|
+
|
|
78
|
+
# Features
|
|
79
|
+
features: list[TierFeature] = Field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
# Limits
|
|
82
|
+
limits: dict[str, Any] = Field(default_factory=dict, description="Resource limits")
|
|
83
|
+
|
|
84
|
+
# Display
|
|
85
|
+
display_order: int = Field(default=0, description="Order for display")
|
|
86
|
+
is_popular: bool = Field(default=False, description="Mark as popular/recommended")
|
|
87
|
+
is_active: bool = Field(default=True, description="Tier is available for subscription")
|
|
88
|
+
is_public: bool = Field(default=True, description="Show in public pricing page")
|
|
89
|
+
|
|
90
|
+
# Annual discount
|
|
91
|
+
annual_discount_percent: Optional[Decimal] = Field(None, description="Discount for annual billing")
|
|
92
|
+
|
|
93
|
+
# Metadata
|
|
94
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
95
|
+
|
|
96
|
+
# Timestamps
|
|
97
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
98
|
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
|
99
|
+
|
|
100
|
+
def get_annual_price(self) -> Decimal:
|
|
101
|
+
"""Calculate annual price with discount."""
|
|
102
|
+
yearly_price = self.price * 12
|
|
103
|
+
|
|
104
|
+
if self.annual_discount_percent:
|
|
105
|
+
discount = yearly_price * (self.annual_discount_percent / 100)
|
|
106
|
+
return yearly_price - discount
|
|
107
|
+
|
|
108
|
+
return yearly_price
|
|
109
|
+
|
|
110
|
+
def get_monthly_equivalent(self, for_annual: bool = False) -> Decimal:
|
|
111
|
+
"""Get monthly equivalent price."""
|
|
112
|
+
if for_annual:
|
|
113
|
+
return self.get_annual_price() / 12
|
|
114
|
+
return self.price
|
|
115
|
+
|
|
116
|
+
def add_feature(
|
|
117
|
+
self,
|
|
118
|
+
feature_id: str,
|
|
119
|
+
name: str,
|
|
120
|
+
description: Optional[str] = None,
|
|
121
|
+
included_quantity: Optional[int] = None,
|
|
122
|
+
limit: Optional[int] = None,
|
|
123
|
+
overage_price: Optional[Decimal] = None,
|
|
124
|
+
) -> TierFeature:
|
|
125
|
+
"""Add a feature to this tier."""
|
|
126
|
+
feature = TierFeature(
|
|
127
|
+
id=feature_id,
|
|
128
|
+
name=name,
|
|
129
|
+
description=description,
|
|
130
|
+
included_quantity=included_quantity,
|
|
131
|
+
limit=limit,
|
|
132
|
+
overage_price=overage_price,
|
|
133
|
+
)
|
|
134
|
+
self.features.append(feature)
|
|
135
|
+
self.updated_at = datetime.utcnow()
|
|
136
|
+
return feature
|
|
137
|
+
|
|
138
|
+
def get_feature(self, feature_id: str) -> Optional[TierFeature]:
|
|
139
|
+
"""Get a feature by ID."""
|
|
140
|
+
for feature in self.features:
|
|
141
|
+
if feature.id == feature_id:
|
|
142
|
+
return feature
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
def has_feature(self, feature_id: str) -> bool:
|
|
146
|
+
"""Check if tier has a feature."""
|
|
147
|
+
return self.get_feature(feature_id) is not None
|
|
148
|
+
|
|
149
|
+
def set_limit(self, key: str, value: Any) -> None:
|
|
150
|
+
"""Set a resource limit."""
|
|
151
|
+
self.limits[key] = value
|
|
152
|
+
self.updated_at = datetime.utcnow()
|
|
153
|
+
|
|
154
|
+
def get_limit(self, key: str, default: Any = None) -> Any:
|
|
155
|
+
"""Get a resource limit."""
|
|
156
|
+
return self.limits.get(key, default)
|
|
157
|
+
|
|
158
|
+
class Config:
|
|
159
|
+
use_enum_values = True
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# Preset tier templates
|
|
163
|
+
class TierTemplates:
|
|
164
|
+
"""Common tier templates for quick setup."""
|
|
165
|
+
|
|
166
|
+
@staticmethod
|
|
167
|
+
def free(tier_id: str = "free") -> Tier:
|
|
168
|
+
"""Create a free tier template."""
|
|
169
|
+
return Tier(
|
|
170
|
+
id=tier_id,
|
|
171
|
+
name="Free",
|
|
172
|
+
description="Get started with basic features",
|
|
173
|
+
price=Decimal("0"),
|
|
174
|
+
billing_cycle=BillingCycle.MONTHLY,
|
|
175
|
+
display_order=0,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def basic(
|
|
180
|
+
tier_id: str = "basic",
|
|
181
|
+
price: Decimal = Decimal("29"),
|
|
182
|
+
) -> Tier:
|
|
183
|
+
"""Create a basic tier template."""
|
|
184
|
+
return Tier(
|
|
185
|
+
id=tier_id,
|
|
186
|
+
name="Basic",
|
|
187
|
+
description="Perfect for individuals and small teams",
|
|
188
|
+
price=price,
|
|
189
|
+
billing_cycle=BillingCycle.MONTHLY,
|
|
190
|
+
trial_days=14,
|
|
191
|
+
display_order=1,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
@staticmethod
|
|
195
|
+
def pro(
|
|
196
|
+
tier_id: str = "pro",
|
|
197
|
+
price: Decimal = Decimal("79"),
|
|
198
|
+
) -> Tier:
|
|
199
|
+
"""Create a pro tier template."""
|
|
200
|
+
return Tier(
|
|
201
|
+
id=tier_id,
|
|
202
|
+
name="Pro",
|
|
203
|
+
description="For growing teams that need more power",
|
|
204
|
+
price=price,
|
|
205
|
+
billing_cycle=BillingCycle.MONTHLY,
|
|
206
|
+
trial_days=14,
|
|
207
|
+
is_popular=True,
|
|
208
|
+
display_order=2,
|
|
209
|
+
annual_discount_percent=Decimal("20"),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
@staticmethod
|
|
213
|
+
def enterprise(
|
|
214
|
+
tier_id: str = "enterprise",
|
|
215
|
+
price: Decimal = Decimal("199"),
|
|
216
|
+
) -> Tier:
|
|
217
|
+
"""Create an enterprise tier template."""
|
|
218
|
+
return Tier(
|
|
219
|
+
id=tier_id,
|
|
220
|
+
name="Enterprise",
|
|
221
|
+
description="Advanced features for large organizations",
|
|
222
|
+
price=price,
|
|
223
|
+
billing_cycle=BillingCycle.MONTHLY,
|
|
224
|
+
display_order=3,
|
|
225
|
+
annual_discount_percent=Decimal("25"),
|
|
226
|
+
)
|