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