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.
@@ -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
+ )
@@ -0,0 +1,11 @@
1
+ """
2
+ Subscription management module.
3
+ """
4
+
5
+ from payplus.subscriptions.manager import SubscriptionManager
6
+ from payplus.subscriptions.billing import BillingService
7
+
8
+ __all__ = [
9
+ "SubscriptionManager",
10
+ "BillingService",
11
+ ]