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,600 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Subscription Manager - Main orchestration layer for subscription management.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Callable, Optional
|
|
11
|
+
|
|
12
|
+
from payplus.models.customer import Customer, PaymentMethod
|
|
13
|
+
from payplus.models.subscription import Subscription, SubscriptionStatus, BillingCycle
|
|
14
|
+
from payplus.models.payment import Payment, PaymentStatus
|
|
15
|
+
from payplus.models.invoice import Invoice, InvoiceStatus, InvoiceItem
|
|
16
|
+
from payplus.models.tier import Tier
|
|
17
|
+
from payplus.exceptions import SubscriptionError, PayPlusError
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from payplus.client import PayPlus
|
|
21
|
+
from payplus.subscriptions.storage import StorageBackend
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SubscriptionManager:
|
|
25
|
+
"""
|
|
26
|
+
High-level subscription management for SaaS applications.
|
|
27
|
+
|
|
28
|
+
This class orchestrates the creation and management of subscriptions,
|
|
29
|
+
handling the interaction between PayPlus API and your database.
|
|
30
|
+
|
|
31
|
+
Usage:
|
|
32
|
+
from payplus import PayPlus, SubscriptionManager
|
|
33
|
+
from payplus.subscriptions.storage import SQLAlchemyStorage
|
|
34
|
+
|
|
35
|
+
client = PayPlus(api_key="...", secret_key="...")
|
|
36
|
+
storage = SQLAlchemyStorage(engine)
|
|
37
|
+
|
|
38
|
+
manager = SubscriptionManager(client, storage)
|
|
39
|
+
|
|
40
|
+
# Create a subscription
|
|
41
|
+
subscription = await manager.create_subscription(
|
|
42
|
+
customer_id="cust_123",
|
|
43
|
+
tier_id="pro",
|
|
44
|
+
payment_method_token="token_xxx",
|
|
45
|
+
)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
client: "PayPlus",
|
|
51
|
+
storage: Optional["StorageBackend"] = None,
|
|
52
|
+
):
|
|
53
|
+
"""
|
|
54
|
+
Initialize the subscription manager.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
client: PayPlus API client
|
|
58
|
+
storage: Storage backend for persisting data (optional, uses in-memory if not provided)
|
|
59
|
+
"""
|
|
60
|
+
self.client = client
|
|
61
|
+
self.storage = storage or InMemoryStorage()
|
|
62
|
+
|
|
63
|
+
# Event hooks
|
|
64
|
+
self._hooks: dict[str, list[Callable[..., Any]]] = {
|
|
65
|
+
"subscription.created": [],
|
|
66
|
+
"subscription.activated": [],
|
|
67
|
+
"subscription.canceled": [],
|
|
68
|
+
"subscription.renewed": [],
|
|
69
|
+
"subscription.payment_failed": [],
|
|
70
|
+
"invoice.created": [],
|
|
71
|
+
"invoice.paid": [],
|
|
72
|
+
"payment.succeeded": [],
|
|
73
|
+
"payment.failed": [],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
def on(self, event: str, callback: Callable[..., Any]) -> None:
|
|
77
|
+
"""Register an event hook."""
|
|
78
|
+
if event in self._hooks:
|
|
79
|
+
self._hooks[event].append(callback)
|
|
80
|
+
|
|
81
|
+
def _emit(self, event: str, *args: Any, **kwargs: Any) -> None:
|
|
82
|
+
"""Emit an event to registered hooks."""
|
|
83
|
+
for callback in self._hooks.get(event, []):
|
|
84
|
+
try:
|
|
85
|
+
callback(*args, **kwargs)
|
|
86
|
+
except Exception:
|
|
87
|
+
pass # Don't let hook errors break the flow
|
|
88
|
+
|
|
89
|
+
# ==================== Customer Management ====================
|
|
90
|
+
|
|
91
|
+
async def create_customer(
|
|
92
|
+
self,
|
|
93
|
+
email: str,
|
|
94
|
+
name: Optional[str] = None,
|
|
95
|
+
phone: Optional[str] = None,
|
|
96
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
97
|
+
) -> Customer:
|
|
98
|
+
"""
|
|
99
|
+
Create a new customer.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
email: Customer email
|
|
103
|
+
name: Customer name
|
|
104
|
+
phone: Customer phone
|
|
105
|
+
metadata: Additional metadata
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Created customer
|
|
109
|
+
"""
|
|
110
|
+
customer = Customer(
|
|
111
|
+
id=f"cust_{uuid.uuid4().hex[:12]}",
|
|
112
|
+
email=email,
|
|
113
|
+
name=name,
|
|
114
|
+
phone=phone,
|
|
115
|
+
metadata=metadata or {},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
await self.storage.save_customer(customer)
|
|
119
|
+
return customer
|
|
120
|
+
|
|
121
|
+
async def get_customer(self, customer_id: str) -> Optional[Customer]:
|
|
122
|
+
"""Get a customer by ID."""
|
|
123
|
+
return await self.storage.get_customer(customer_id)
|
|
124
|
+
|
|
125
|
+
async def add_payment_method(
|
|
126
|
+
self,
|
|
127
|
+
customer_id: str,
|
|
128
|
+
token: str,
|
|
129
|
+
card_brand: Optional[str] = None,
|
|
130
|
+
last_four: Optional[str] = None,
|
|
131
|
+
expiry_month: Optional[str] = None,
|
|
132
|
+
expiry_year: Optional[str] = None,
|
|
133
|
+
set_default: bool = True,
|
|
134
|
+
) -> PaymentMethod:
|
|
135
|
+
"""
|
|
136
|
+
Add a payment method to a customer.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
customer_id: Customer ID
|
|
140
|
+
token: PayPlus card token
|
|
141
|
+
card_brand: Card brand
|
|
142
|
+
last_four: Last 4 digits
|
|
143
|
+
expiry_month: Card expiry month
|
|
144
|
+
expiry_year: Card expiry year
|
|
145
|
+
set_default: Set as default payment method
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Created payment method
|
|
149
|
+
"""
|
|
150
|
+
customer = await self.get_customer(customer_id)
|
|
151
|
+
if not customer:
|
|
152
|
+
raise SubscriptionError(f"Customer {customer_id} not found")
|
|
153
|
+
|
|
154
|
+
pm = customer.add_payment_method(
|
|
155
|
+
token=token,
|
|
156
|
+
card_brand=card_brand,
|
|
157
|
+
last_four=last_four,
|
|
158
|
+
expiry_month=expiry_month,
|
|
159
|
+
expiry_year=expiry_year,
|
|
160
|
+
set_default=set_default,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
await self.storage.save_customer(customer)
|
|
164
|
+
return pm
|
|
165
|
+
|
|
166
|
+
# ==================== Tier Management ====================
|
|
167
|
+
|
|
168
|
+
async def create_tier(
|
|
169
|
+
self,
|
|
170
|
+
tier_id: str,
|
|
171
|
+
name: str,
|
|
172
|
+
price: Decimal,
|
|
173
|
+
billing_cycle: BillingCycle = BillingCycle.MONTHLY,
|
|
174
|
+
trial_days: int = 0,
|
|
175
|
+
features: Optional[list[dict[str, Any]]] = None,
|
|
176
|
+
**kwargs: Any,
|
|
177
|
+
) -> Tier:
|
|
178
|
+
"""
|
|
179
|
+
Create a pricing tier.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
tier_id: Unique tier ID
|
|
183
|
+
name: Tier display name
|
|
184
|
+
price: Price per billing cycle
|
|
185
|
+
billing_cycle: Billing cycle
|
|
186
|
+
trial_days: Trial period in days
|
|
187
|
+
features: List of feature configs
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Created tier
|
|
191
|
+
"""
|
|
192
|
+
tier = Tier(
|
|
193
|
+
id=tier_id,
|
|
194
|
+
name=name,
|
|
195
|
+
price=price,
|
|
196
|
+
billing_cycle=billing_cycle,
|
|
197
|
+
trial_days=trial_days,
|
|
198
|
+
**kwargs,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if features:
|
|
202
|
+
for f in features:
|
|
203
|
+
tier.add_feature(**f)
|
|
204
|
+
|
|
205
|
+
await self.storage.save_tier(tier)
|
|
206
|
+
return tier
|
|
207
|
+
|
|
208
|
+
async def get_tier(self, tier_id: str) -> Optional[Tier]:
|
|
209
|
+
"""Get a tier by ID."""
|
|
210
|
+
return await self.storage.get_tier(tier_id)
|
|
211
|
+
|
|
212
|
+
async def list_tiers(self, active_only: bool = True) -> list[Tier]:
|
|
213
|
+
"""List all tiers."""
|
|
214
|
+
return await self.storage.list_tiers(active_only=active_only)
|
|
215
|
+
|
|
216
|
+
# ==================== Subscription Management ====================
|
|
217
|
+
|
|
218
|
+
async def create_subscription(
|
|
219
|
+
self,
|
|
220
|
+
customer_id: str,
|
|
221
|
+
tier_id: str,
|
|
222
|
+
payment_method_token: Optional[str] = None,
|
|
223
|
+
payment_method_id: Optional[str] = None,
|
|
224
|
+
trial_days: Optional[int] = None,
|
|
225
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
226
|
+
) -> Subscription:
|
|
227
|
+
"""
|
|
228
|
+
Create a new subscription.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
customer_id: Customer ID
|
|
232
|
+
tier_id: Pricing tier ID
|
|
233
|
+
payment_method_token: PayPlus card token (for new cards)
|
|
234
|
+
payment_method_id: Existing payment method ID
|
|
235
|
+
trial_days: Override trial period
|
|
236
|
+
metadata: Additional metadata
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Created subscription
|
|
240
|
+
"""
|
|
241
|
+
# Get customer and tier
|
|
242
|
+
customer = await self.get_customer(customer_id)
|
|
243
|
+
if not customer:
|
|
244
|
+
raise SubscriptionError(f"Customer {customer_id} not found")
|
|
245
|
+
|
|
246
|
+
tier = await self.get_tier(tier_id)
|
|
247
|
+
if not tier:
|
|
248
|
+
raise SubscriptionError(f"Tier {tier_id} not found")
|
|
249
|
+
|
|
250
|
+
# Get payment method
|
|
251
|
+
if payment_method_token:
|
|
252
|
+
pm = await self.add_payment_method(customer_id, payment_method_token)
|
|
253
|
+
payment_method_id = pm.id
|
|
254
|
+
elif not payment_method_id:
|
|
255
|
+
pm = customer.get_default_payment_method()
|
|
256
|
+
if pm:
|
|
257
|
+
payment_method_id = pm.id
|
|
258
|
+
|
|
259
|
+
# Determine trial period
|
|
260
|
+
effective_trial_days = trial_days if trial_days is not None else tier.trial_days
|
|
261
|
+
|
|
262
|
+
now = datetime.utcnow()
|
|
263
|
+
trial_end = None
|
|
264
|
+
initial_status = SubscriptionStatus.ACTIVE
|
|
265
|
+
|
|
266
|
+
if effective_trial_days > 0:
|
|
267
|
+
trial_end = now + timedelta(days=effective_trial_days)
|
|
268
|
+
initial_status = SubscriptionStatus.TRIALING
|
|
269
|
+
|
|
270
|
+
# Calculate period end
|
|
271
|
+
interval, interval_count = tier.billing_cycle.to_interval()
|
|
272
|
+
period_end = self._calculate_period_end(now, interval, interval_count)
|
|
273
|
+
|
|
274
|
+
# Create subscription
|
|
275
|
+
subscription = Subscription(
|
|
276
|
+
id=f"sub_{uuid.uuid4().hex[:12]}",
|
|
277
|
+
customer_id=customer_id,
|
|
278
|
+
tier_id=tier_id,
|
|
279
|
+
payment_method_id=payment_method_id,
|
|
280
|
+
status=initial_status,
|
|
281
|
+
amount=tier.price,
|
|
282
|
+
currency=tier.currency,
|
|
283
|
+
billing_cycle=tier.billing_cycle,
|
|
284
|
+
trial_start=now if effective_trial_days > 0 else None,
|
|
285
|
+
trial_end=trial_end,
|
|
286
|
+
current_period_start=now,
|
|
287
|
+
current_period_end=period_end,
|
|
288
|
+
metadata=metadata or {},
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# If not trialing and we have a payment method, charge immediately
|
|
292
|
+
if initial_status == SubscriptionStatus.ACTIVE and payment_method_id and tier.price > 0:
|
|
293
|
+
invoice = await self._create_subscription_invoice(subscription, tier)
|
|
294
|
+
await self._charge_invoice(invoice, customer, subscription)
|
|
295
|
+
|
|
296
|
+
await self.storage.save_subscription(subscription)
|
|
297
|
+
self._emit("subscription.created", subscription)
|
|
298
|
+
|
|
299
|
+
if initial_status == SubscriptionStatus.ACTIVE:
|
|
300
|
+
self._emit("subscription.activated", subscription)
|
|
301
|
+
|
|
302
|
+
return subscription
|
|
303
|
+
|
|
304
|
+
async def get_subscription(self, subscription_id: str) -> Optional[Subscription]:
|
|
305
|
+
"""Get a subscription by ID."""
|
|
306
|
+
return await self.storage.get_subscription(subscription_id)
|
|
307
|
+
|
|
308
|
+
async def cancel_subscription(
|
|
309
|
+
self,
|
|
310
|
+
subscription_id: str,
|
|
311
|
+
at_period_end: bool = True,
|
|
312
|
+
reason: Optional[str] = None,
|
|
313
|
+
) -> Subscription:
|
|
314
|
+
"""
|
|
315
|
+
Cancel a subscription.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
subscription_id: Subscription ID
|
|
319
|
+
at_period_end: Cancel at end of billing period
|
|
320
|
+
reason: Cancellation reason
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Updated subscription
|
|
324
|
+
"""
|
|
325
|
+
subscription = await self.get_subscription(subscription_id)
|
|
326
|
+
if not subscription:
|
|
327
|
+
raise SubscriptionError(f"Subscription {subscription_id} not found")
|
|
328
|
+
|
|
329
|
+
subscription.cancel(at_period_end=at_period_end, reason=reason)
|
|
330
|
+
|
|
331
|
+
# Cancel PayPlus recurring if exists
|
|
332
|
+
if subscription.payplus_recurring_uid:
|
|
333
|
+
try:
|
|
334
|
+
self.client.recurring.cancel(subscription.payplus_recurring_uid)
|
|
335
|
+
except PayPlusError:
|
|
336
|
+
pass # Don't fail if PayPlus cancel fails
|
|
337
|
+
|
|
338
|
+
await self.storage.save_subscription(subscription)
|
|
339
|
+
self._emit("subscription.canceled", subscription)
|
|
340
|
+
|
|
341
|
+
return subscription
|
|
342
|
+
|
|
343
|
+
async def change_tier(
|
|
344
|
+
self,
|
|
345
|
+
subscription_id: str,
|
|
346
|
+
new_tier_id: str,
|
|
347
|
+
prorate: bool = True,
|
|
348
|
+
) -> Subscription:
|
|
349
|
+
"""
|
|
350
|
+
Change subscription tier (upgrade/downgrade).
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
subscription_id: Subscription ID
|
|
354
|
+
new_tier_id: New tier ID
|
|
355
|
+
prorate: Prorate the change
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
Updated subscription
|
|
359
|
+
"""
|
|
360
|
+
subscription = await self.get_subscription(subscription_id)
|
|
361
|
+
if not subscription:
|
|
362
|
+
raise SubscriptionError(f"Subscription {subscription_id} not found")
|
|
363
|
+
|
|
364
|
+
new_tier = await self.get_tier(new_tier_id)
|
|
365
|
+
if not new_tier:
|
|
366
|
+
raise SubscriptionError(f"Tier {new_tier_id} not found")
|
|
367
|
+
|
|
368
|
+
old_amount = subscription.amount
|
|
369
|
+
subscription.change_tier(new_tier_id, new_tier.price)
|
|
370
|
+
|
|
371
|
+
# Handle proration if upgrading
|
|
372
|
+
if prorate and new_tier.price > old_amount:
|
|
373
|
+
# Calculate prorated amount for remainder of period
|
|
374
|
+
# This is a simplified proration - you may want more sophisticated logic
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
await self.storage.save_subscription(subscription)
|
|
378
|
+
return subscription
|
|
379
|
+
|
|
380
|
+
async def pause_subscription(
|
|
381
|
+
self,
|
|
382
|
+
subscription_id: str,
|
|
383
|
+
resume_at: Optional[datetime] = None,
|
|
384
|
+
) -> Subscription:
|
|
385
|
+
"""Pause a subscription."""
|
|
386
|
+
subscription = await self.get_subscription(subscription_id)
|
|
387
|
+
if not subscription:
|
|
388
|
+
raise SubscriptionError(f"Subscription {subscription_id} not found")
|
|
389
|
+
|
|
390
|
+
subscription.pause(resume_at=resume_at)
|
|
391
|
+
await self.storage.save_subscription(subscription)
|
|
392
|
+
return subscription
|
|
393
|
+
|
|
394
|
+
async def resume_subscription(self, subscription_id: str) -> Subscription:
|
|
395
|
+
"""Resume a paused subscription."""
|
|
396
|
+
subscription = await self.get_subscription(subscription_id)
|
|
397
|
+
if not subscription:
|
|
398
|
+
raise SubscriptionError(f"Subscription {subscription_id} not found")
|
|
399
|
+
|
|
400
|
+
subscription.resume()
|
|
401
|
+
await self.storage.save_subscription(subscription)
|
|
402
|
+
return subscription
|
|
403
|
+
|
|
404
|
+
# ==================== Billing ====================
|
|
405
|
+
|
|
406
|
+
async def _create_subscription_invoice(
|
|
407
|
+
self,
|
|
408
|
+
subscription: Subscription,
|
|
409
|
+
tier: Tier,
|
|
410
|
+
) -> Invoice:
|
|
411
|
+
"""Create an invoice for a subscription."""
|
|
412
|
+
invoice = Invoice(
|
|
413
|
+
id=f"inv_{uuid.uuid4().hex[:12]}",
|
|
414
|
+
customer_id=subscription.customer_id,
|
|
415
|
+
subscription_id=subscription.id,
|
|
416
|
+
status=InvoiceStatus.DRAFT,
|
|
417
|
+
currency=subscription.currency,
|
|
418
|
+
period_start=subscription.current_period_start,
|
|
419
|
+
period_end=subscription.current_period_end,
|
|
420
|
+
billing_reason="subscription_cycle",
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
invoice.add_item(
|
|
424
|
+
item_id=f"ii_{uuid.uuid4().hex[:8]}",
|
|
425
|
+
description=f"{tier.name} - {subscription.billing_cycle.value} subscription",
|
|
426
|
+
unit_amount=subscription.amount,
|
|
427
|
+
quantity=1,
|
|
428
|
+
subscription_id=subscription.id,
|
|
429
|
+
tier_id=tier.id,
|
|
430
|
+
period_start=subscription.current_period_start,
|
|
431
|
+
period_end=subscription.current_period_end,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
invoice.finalize()
|
|
435
|
+
await self.storage.save_invoice(invoice)
|
|
436
|
+
self._emit("invoice.created", invoice)
|
|
437
|
+
|
|
438
|
+
return invoice
|
|
439
|
+
|
|
440
|
+
async def _charge_invoice(
|
|
441
|
+
self,
|
|
442
|
+
invoice: Invoice,
|
|
443
|
+
customer: Customer,
|
|
444
|
+
subscription: Subscription,
|
|
445
|
+
) -> Payment:
|
|
446
|
+
"""Charge an invoice."""
|
|
447
|
+
pm = customer.get_default_payment_method()
|
|
448
|
+
if not pm:
|
|
449
|
+
raise SubscriptionError("No payment method available")
|
|
450
|
+
|
|
451
|
+
payment = Payment(
|
|
452
|
+
id=f"pay_{uuid.uuid4().hex[:12]}",
|
|
453
|
+
customer_id=customer.id,
|
|
454
|
+
subscription_id=subscription.id,
|
|
455
|
+
invoice_id=invoice.id,
|
|
456
|
+
amount=invoice.total,
|
|
457
|
+
currency=invoice.currency,
|
|
458
|
+
payment_method_id=pm.id,
|
|
459
|
+
card_last_four=pm.last_four,
|
|
460
|
+
card_brand=pm.card_brand,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
try:
|
|
464
|
+
# Charge via PayPlus
|
|
465
|
+
result = self.client.transactions.charge(
|
|
466
|
+
token=pm.token,
|
|
467
|
+
amount=float(invoice.total),
|
|
468
|
+
currency=invoice.currency,
|
|
469
|
+
description=f"Invoice {invoice.id}",
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
transaction_uid = result.get("data", {}).get("transaction_uid")
|
|
473
|
+
approval_number = result.get("data", {}).get("approval_number")
|
|
474
|
+
|
|
475
|
+
payment.mark_succeeded(
|
|
476
|
+
transaction_uid=transaction_uid,
|
|
477
|
+
approval_number=approval_number,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
invoice.mark_paid(payment.id)
|
|
481
|
+
subscription.mark_payment_succeeded()
|
|
482
|
+
|
|
483
|
+
self._emit("payment.succeeded", payment)
|
|
484
|
+
self._emit("invoice.paid", invoice)
|
|
485
|
+
|
|
486
|
+
except PayPlusError as e:
|
|
487
|
+
payment.mark_failed(
|
|
488
|
+
failure_code=str(e.status_code) if hasattr(e, "status_code") else None,
|
|
489
|
+
failure_message=str(e),
|
|
490
|
+
)
|
|
491
|
+
subscription.mark_payment_failed()
|
|
492
|
+
|
|
493
|
+
self._emit("payment.failed", payment)
|
|
494
|
+
self._emit("subscription.payment_failed", subscription)
|
|
495
|
+
|
|
496
|
+
await self.storage.save_payment(payment)
|
|
497
|
+
await self.storage.save_invoice(invoice)
|
|
498
|
+
await self.storage.save_subscription(subscription)
|
|
499
|
+
|
|
500
|
+
return payment
|
|
501
|
+
|
|
502
|
+
async def renew_subscription(self, subscription_id: str) -> Optional[Invoice]:
|
|
503
|
+
"""
|
|
504
|
+
Renew a subscription (create invoice and charge).
|
|
505
|
+
|
|
506
|
+
This is typically called by a scheduled job.
|
|
507
|
+
"""
|
|
508
|
+
subscription = await self.get_subscription(subscription_id)
|
|
509
|
+
if not subscription or not subscription.will_renew:
|
|
510
|
+
return None
|
|
511
|
+
|
|
512
|
+
customer = await self.get_customer(subscription.customer_id)
|
|
513
|
+
tier = await self.get_tier(subscription.tier_id)
|
|
514
|
+
|
|
515
|
+
if not customer or not tier:
|
|
516
|
+
return None
|
|
517
|
+
|
|
518
|
+
# Update period
|
|
519
|
+
interval, interval_count = subscription.billing_cycle.to_interval()
|
|
520
|
+
subscription.current_period_start = subscription.current_period_end
|
|
521
|
+
subscription.current_period_end = self._calculate_period_end(
|
|
522
|
+
subscription.current_period_start, interval, interval_count
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
invoice = await self._create_subscription_invoice(subscription, tier)
|
|
526
|
+
await self._charge_invoice(invoice, customer, subscription)
|
|
527
|
+
|
|
528
|
+
self._emit("subscription.renewed", subscription)
|
|
529
|
+
|
|
530
|
+
return invoice
|
|
531
|
+
|
|
532
|
+
def _calculate_period_end(
|
|
533
|
+
self,
|
|
534
|
+
start: datetime,
|
|
535
|
+
interval: str,
|
|
536
|
+
interval_count: int,
|
|
537
|
+
) -> datetime:
|
|
538
|
+
"""Calculate the end of a billing period."""
|
|
539
|
+
if interval == "day":
|
|
540
|
+
return start + timedelta(days=interval_count)
|
|
541
|
+
elif interval == "week":
|
|
542
|
+
return start + timedelta(weeks=interval_count)
|
|
543
|
+
elif interval == "month":
|
|
544
|
+
# Add months (simplified)
|
|
545
|
+
month = start.month + interval_count
|
|
546
|
+
year = start.year + (month - 1) // 12
|
|
547
|
+
month = ((month - 1) % 12) + 1
|
|
548
|
+
day = min(start.day, 28) # Avoid invalid dates
|
|
549
|
+
return start.replace(year=year, month=month, day=day)
|
|
550
|
+
elif interval == "year":
|
|
551
|
+
return start.replace(year=start.year + interval_count)
|
|
552
|
+
else:
|
|
553
|
+
return start + timedelta(days=30 * interval_count)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
class InMemoryStorage:
|
|
557
|
+
"""Simple in-memory storage for development/testing."""
|
|
558
|
+
|
|
559
|
+
def __init__(self) -> None:
|
|
560
|
+
self.customers: dict[str, Customer] = {}
|
|
561
|
+
self.tiers: dict[str, Tier] = {}
|
|
562
|
+
self.subscriptions: dict[str, Subscription] = {}
|
|
563
|
+
self.invoices: dict[str, Invoice] = {}
|
|
564
|
+
self.payments: dict[str, Payment] = {}
|
|
565
|
+
|
|
566
|
+
async def save_customer(self, customer: Customer) -> None:
|
|
567
|
+
self.customers[customer.id] = customer
|
|
568
|
+
|
|
569
|
+
async def get_customer(self, customer_id: str) -> Optional[Customer]:
|
|
570
|
+
return self.customers.get(customer_id)
|
|
571
|
+
|
|
572
|
+
async def save_tier(self, tier: Tier) -> None:
|
|
573
|
+
self.tiers[tier.id] = tier
|
|
574
|
+
|
|
575
|
+
async def get_tier(self, tier_id: str) -> Optional[Tier]:
|
|
576
|
+
return self.tiers.get(tier_id)
|
|
577
|
+
|
|
578
|
+
async def list_tiers(self, active_only: bool = True) -> list[Tier]:
|
|
579
|
+
tiers = list(self.tiers.values())
|
|
580
|
+
if active_only:
|
|
581
|
+
tiers = [t for t in tiers if t.is_active]
|
|
582
|
+
return sorted(tiers, key=lambda t: t.display_order)
|
|
583
|
+
|
|
584
|
+
async def save_subscription(self, subscription: Subscription) -> None:
|
|
585
|
+
self.subscriptions[subscription.id] = subscription
|
|
586
|
+
|
|
587
|
+
async def get_subscription(self, subscription_id: str) -> Optional[Subscription]:
|
|
588
|
+
return self.subscriptions.get(subscription_id)
|
|
589
|
+
|
|
590
|
+
async def save_invoice(self, invoice: Invoice) -> None:
|
|
591
|
+
self.invoices[invoice.id] = invoice
|
|
592
|
+
|
|
593
|
+
async def get_invoice(self, invoice_id: str) -> Optional[Invoice]:
|
|
594
|
+
return self.invoices.get(invoice_id)
|
|
595
|
+
|
|
596
|
+
async def save_payment(self, payment: Payment) -> None:
|
|
597
|
+
self.payments[payment.id] = payment
|
|
598
|
+
|
|
599
|
+
async def get_payment(self, payment_id: str) -> Optional[Payment]:
|
|
600
|
+
return self.payments.get(payment_id)
|