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,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)