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,231 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Billing Service - Handle billing cycles and scheduled tasks.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from typing import TYPE_CHECKING, Optional
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from payplus.subscriptions.manager import SubscriptionManager
|
|
12
|
+
from payplus.models.subscription import Subscription
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BillingService:
|
|
16
|
+
"""
|
|
17
|
+
Service for handling billing cycles and scheduled renewal tasks.
|
|
18
|
+
|
|
19
|
+
Usage with your scheduler (e.g., Celery, APScheduler):
|
|
20
|
+
|
|
21
|
+
from payplus import PayPlus, SubscriptionManager
|
|
22
|
+
from payplus.subscriptions import BillingService
|
|
23
|
+
|
|
24
|
+
manager = SubscriptionManager(client, storage)
|
|
25
|
+
billing = BillingService(manager)
|
|
26
|
+
|
|
27
|
+
# Run daily
|
|
28
|
+
async def daily_billing_job():
|
|
29
|
+
await billing.process_due_renewals()
|
|
30
|
+
await billing.process_trial_endings()
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, manager: "SubscriptionManager"):
|
|
34
|
+
self.manager = manager
|
|
35
|
+
|
|
36
|
+
async def process_due_renewals(self) -> list[str]:
|
|
37
|
+
"""
|
|
38
|
+
Process all subscriptions due for renewal.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
List of processed subscription IDs
|
|
42
|
+
"""
|
|
43
|
+
processed = []
|
|
44
|
+
now = datetime.utcnow()
|
|
45
|
+
|
|
46
|
+
# Get subscriptions due for renewal
|
|
47
|
+
# In production, this would query the database
|
|
48
|
+
subscriptions = await self._get_subscriptions_due_for_renewal(now)
|
|
49
|
+
|
|
50
|
+
for subscription in subscriptions:
|
|
51
|
+
try:
|
|
52
|
+
invoice = await self.manager.renew_subscription(subscription.id)
|
|
53
|
+
if invoice:
|
|
54
|
+
processed.append(subscription.id)
|
|
55
|
+
except Exception:
|
|
56
|
+
# Log error but continue with other subscriptions
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
return processed
|
|
60
|
+
|
|
61
|
+
async def process_trial_endings(self) -> list[str]:
|
|
62
|
+
"""
|
|
63
|
+
Process subscriptions whose trials are ending.
|
|
64
|
+
|
|
65
|
+
This converts trialing subscriptions to active and charges them.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
List of processed subscription IDs
|
|
69
|
+
"""
|
|
70
|
+
processed = []
|
|
71
|
+
now = datetime.utcnow()
|
|
72
|
+
|
|
73
|
+
subscriptions = await self._get_trials_ending_soon(now)
|
|
74
|
+
|
|
75
|
+
for subscription in subscriptions:
|
|
76
|
+
try:
|
|
77
|
+
# Convert trial to active subscription
|
|
78
|
+
await self._convert_trial_to_active(subscription)
|
|
79
|
+
processed.append(subscription.id)
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
return processed
|
|
84
|
+
|
|
85
|
+
async def process_past_due(self) -> list[str]:
|
|
86
|
+
"""
|
|
87
|
+
Retry failed payments for past due subscriptions.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
List of processed subscription IDs
|
|
91
|
+
"""
|
|
92
|
+
processed = []
|
|
93
|
+
|
|
94
|
+
subscriptions = await self._get_past_due_subscriptions()
|
|
95
|
+
|
|
96
|
+
for subscription in subscriptions:
|
|
97
|
+
try:
|
|
98
|
+
# Get the open invoice
|
|
99
|
+
# Retry the payment
|
|
100
|
+
# Update status based on result
|
|
101
|
+
processed.append(subscription.id)
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
return processed
|
|
106
|
+
|
|
107
|
+
async def process_cancellations(self) -> list[str]:
|
|
108
|
+
"""
|
|
109
|
+
Process subscriptions scheduled for cancellation at period end.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
List of processed subscription IDs
|
|
113
|
+
"""
|
|
114
|
+
processed = []
|
|
115
|
+
now = datetime.utcnow()
|
|
116
|
+
|
|
117
|
+
subscriptions = await self._get_pending_cancellations(now)
|
|
118
|
+
|
|
119
|
+
for subscription in subscriptions:
|
|
120
|
+
try:
|
|
121
|
+
subscription.status = "canceled"
|
|
122
|
+
subscription.ended_at = now
|
|
123
|
+
await self.manager.storage.save_subscription(subscription)
|
|
124
|
+
processed.append(subscription.id)
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
return processed
|
|
129
|
+
|
|
130
|
+
async def _get_subscriptions_due_for_renewal(
|
|
131
|
+
self,
|
|
132
|
+
as_of: datetime,
|
|
133
|
+
) -> list["Subscription"]:
|
|
134
|
+
"""Get subscriptions due for renewal."""
|
|
135
|
+
# In production, this would be a database query
|
|
136
|
+
# For now, iterate through in-memory storage
|
|
137
|
+
subscriptions = []
|
|
138
|
+
|
|
139
|
+
for sub_id in list(self.manager.storage.subscriptions.keys()):
|
|
140
|
+
sub = await self.manager.storage.get_subscription(sub_id)
|
|
141
|
+
if not sub:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
if (
|
|
145
|
+
sub.will_renew
|
|
146
|
+
and sub.current_period_end
|
|
147
|
+
and sub.current_period_end <= as_of
|
|
148
|
+
):
|
|
149
|
+
subscriptions.append(sub)
|
|
150
|
+
|
|
151
|
+
return subscriptions
|
|
152
|
+
|
|
153
|
+
async def _get_trials_ending_soon(
|
|
154
|
+
self,
|
|
155
|
+
as_of: datetime,
|
|
156
|
+
within_hours: int = 24,
|
|
157
|
+
) -> list["Subscription"]:
|
|
158
|
+
"""Get trials ending within specified hours."""
|
|
159
|
+
subscriptions = []
|
|
160
|
+
cutoff = as_of + timedelta(hours=within_hours)
|
|
161
|
+
|
|
162
|
+
for sub_id in list(self.manager.storage.subscriptions.keys()):
|
|
163
|
+
sub = await self.manager.storage.get_subscription(sub_id)
|
|
164
|
+
if not sub:
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
if (
|
|
168
|
+
sub.is_trialing
|
|
169
|
+
and sub.trial_end
|
|
170
|
+
and sub.trial_end <= cutoff
|
|
171
|
+
):
|
|
172
|
+
subscriptions.append(sub)
|
|
173
|
+
|
|
174
|
+
return subscriptions
|
|
175
|
+
|
|
176
|
+
async def _get_past_due_subscriptions(self) -> list["Subscription"]:
|
|
177
|
+
"""Get past due subscriptions for retry."""
|
|
178
|
+
subscriptions = []
|
|
179
|
+
|
|
180
|
+
for sub_id in list(self.manager.storage.subscriptions.keys()):
|
|
181
|
+
sub = await self.manager.storage.get_subscription(sub_id)
|
|
182
|
+
if not sub:
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
if sub.status == "past_due" and sub.failed_payment_count < 4:
|
|
186
|
+
subscriptions.append(sub)
|
|
187
|
+
|
|
188
|
+
return subscriptions
|
|
189
|
+
|
|
190
|
+
async def _get_pending_cancellations(
|
|
191
|
+
self,
|
|
192
|
+
as_of: datetime,
|
|
193
|
+
) -> list["Subscription"]:
|
|
194
|
+
"""Get subscriptions pending cancellation at period end."""
|
|
195
|
+
subscriptions = []
|
|
196
|
+
|
|
197
|
+
for sub_id in list(self.manager.storage.subscriptions.keys()):
|
|
198
|
+
sub = await self.manager.storage.get_subscription(sub_id)
|
|
199
|
+
if not sub:
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
if (
|
|
203
|
+
sub.cancel_at_period_end
|
|
204
|
+
and sub.current_period_end
|
|
205
|
+
and sub.current_period_end <= as_of
|
|
206
|
+
and sub.status != "canceled"
|
|
207
|
+
):
|
|
208
|
+
subscriptions.append(sub)
|
|
209
|
+
|
|
210
|
+
return subscriptions
|
|
211
|
+
|
|
212
|
+
async def _convert_trial_to_active(
|
|
213
|
+
self,
|
|
214
|
+
subscription: "Subscription",
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Convert a trial subscription to active."""
|
|
217
|
+
from payplus.models.subscription import SubscriptionStatus
|
|
218
|
+
|
|
219
|
+
subscription.status = SubscriptionStatus.ACTIVE
|
|
220
|
+
subscription.trial_end = None
|
|
221
|
+
subscription.updated_at = datetime.utcnow()
|
|
222
|
+
|
|
223
|
+
# Create invoice and charge
|
|
224
|
+
customer = await self.manager.get_customer(subscription.customer_id)
|
|
225
|
+
tier = await self.manager.get_tier(subscription.tier_id)
|
|
226
|
+
|
|
227
|
+
if customer and tier and tier.price > 0:
|
|
228
|
+
invoice = await self.manager._create_subscription_invoice(subscription, tier)
|
|
229
|
+
await self.manager._charge_invoice(invoice, customer, subscription)
|
|
230
|
+
else:
|
|
231
|
+
await self.manager.storage.save_subscription(subscription)
|