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,571 @@
1
+ """
2
+ Storage backends for subscription data persistence.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from abc import ABC, abstractmethod
8
+ from typing import Any, Optional
9
+
10
+ from payplus.models.customer import Customer
11
+ from payplus.models.subscription import Subscription
12
+ from payplus.models.payment import Payment
13
+ from payplus.models.invoice import Invoice
14
+ from payplus.models.tier import Tier
15
+
16
+
17
+ class StorageBackend(ABC):
18
+ """Abstract base class for storage backends."""
19
+
20
+ # Customer operations
21
+ @abstractmethod
22
+ async def save_customer(self, customer: Customer) -> None:
23
+ """Save a customer."""
24
+ pass
25
+
26
+ @abstractmethod
27
+ async def get_customer(self, customer_id: str) -> Optional[Customer]:
28
+ """Get a customer by ID."""
29
+ pass
30
+
31
+ @abstractmethod
32
+ async def get_customer_by_email(self, email: str) -> Optional[Customer]:
33
+ """Get a customer by email."""
34
+ pass
35
+
36
+ # Tier operations
37
+ @abstractmethod
38
+ async def save_tier(self, tier: Tier) -> None:
39
+ """Save a tier."""
40
+ pass
41
+
42
+ @abstractmethod
43
+ async def get_tier(self, tier_id: str) -> Optional[Tier]:
44
+ """Get a tier by ID."""
45
+ pass
46
+
47
+ @abstractmethod
48
+ async def list_tiers(self, active_only: bool = True) -> list[Tier]:
49
+ """List all tiers."""
50
+ pass
51
+
52
+ # Subscription operations
53
+ @abstractmethod
54
+ async def save_subscription(self, subscription: Subscription) -> None:
55
+ """Save a subscription."""
56
+ pass
57
+
58
+ @abstractmethod
59
+ async def get_subscription(self, subscription_id: str) -> Optional[Subscription]:
60
+ """Get a subscription by ID."""
61
+ pass
62
+
63
+ @abstractmethod
64
+ async def list_subscriptions_by_customer(
65
+ self,
66
+ customer_id: str,
67
+ active_only: bool = False,
68
+ ) -> list[Subscription]:
69
+ """List subscriptions for a customer."""
70
+ pass
71
+
72
+ # Invoice operations
73
+ @abstractmethod
74
+ async def save_invoice(self, invoice: Invoice) -> None:
75
+ """Save an invoice."""
76
+ pass
77
+
78
+ @abstractmethod
79
+ async def get_invoice(self, invoice_id: str) -> Optional[Invoice]:
80
+ """Get an invoice by ID."""
81
+ pass
82
+
83
+ @abstractmethod
84
+ async def list_invoices_by_customer(
85
+ self,
86
+ customer_id: str,
87
+ limit: int = 100,
88
+ ) -> list[Invoice]:
89
+ """List invoices for a customer."""
90
+ pass
91
+
92
+ # Payment operations
93
+ @abstractmethod
94
+ async def save_payment(self, payment: Payment) -> None:
95
+ """Save a payment."""
96
+ pass
97
+
98
+ @abstractmethod
99
+ async def get_payment(self, payment_id: str) -> Optional[Payment]:
100
+ """Get a payment by ID."""
101
+ pass
102
+
103
+
104
+ class SQLAlchemyStorage(StorageBackend):
105
+ """
106
+ SQLAlchemy storage backend.
107
+
108
+ Usage:
109
+ from sqlalchemy.ext.asyncio import create_async_engine
110
+ from payplus.subscriptions.storage import SQLAlchemyStorage
111
+
112
+ engine = create_async_engine("postgresql+asyncpg://...")
113
+ storage = SQLAlchemyStorage(engine)
114
+
115
+ # Create tables
116
+ await storage.create_tables()
117
+ """
118
+
119
+ def __init__(self, engine: Any):
120
+ """
121
+ Initialize SQLAlchemy storage.
122
+
123
+ Args:
124
+ engine: SQLAlchemy async engine
125
+ """
126
+ self.engine = engine
127
+ self._tables_created = False
128
+
129
+ async def create_tables(self) -> None:
130
+ """Create database tables."""
131
+ from sqlalchemy import (
132
+ MetaData, Table, Column, String, Boolean, DateTime,
133
+ Numeric, Integer, JSON, Text, ForeignKey
134
+ )
135
+
136
+ metadata = MetaData()
137
+
138
+ # Customers table
139
+ Table(
140
+ "payplus_customers",
141
+ metadata,
142
+ Column("id", String(50), primary_key=True),
143
+ Column("email", String(255), unique=True, nullable=False),
144
+ Column("name", String(255)),
145
+ Column("phone", String(50)),
146
+ Column("company", String(255)),
147
+ Column("payplus_customer_uid", String(100)),
148
+ Column("payment_methods", JSON, default=[]),
149
+ Column("default_payment_method_id", String(50)),
150
+ Column("status", String(20), default="active"),
151
+ Column("address_line1", String(255)),
152
+ Column("address_line2", String(255)),
153
+ Column("city", String(100)),
154
+ Column("state", String(100)),
155
+ Column("postal_code", String(20)),
156
+ Column("country", String(2), default="IL"),
157
+ Column("tax_id", String(50)),
158
+ Column("metadata", JSON, default={}),
159
+ Column("created_at", DateTime),
160
+ Column("updated_at", DateTime),
161
+ )
162
+
163
+ # Tiers table
164
+ Table(
165
+ "payplus_tiers",
166
+ metadata,
167
+ Column("id", String(50), primary_key=True),
168
+ Column("name", String(100), nullable=False),
169
+ Column("description", Text),
170
+ Column("price", Numeric(10, 2), nullable=False),
171
+ Column("currency", String(3), default="ILS"),
172
+ Column("billing_cycle", String(20), default="monthly"),
173
+ Column("tier_type", String(20), default="flat"),
174
+ Column("usage_type", String(20), default="licensed"),
175
+ Column("trial_days", Integer, default=0),
176
+ Column("features", JSON, default=[]),
177
+ Column("limits", JSON, default={}),
178
+ Column("display_order", Integer, default=0),
179
+ Column("is_popular", Boolean, default=False),
180
+ Column("is_active", Boolean, default=True),
181
+ Column("is_public", Boolean, default=True),
182
+ Column("annual_discount_percent", Numeric(5, 2)),
183
+ Column("metadata", JSON, default={}),
184
+ Column("created_at", DateTime),
185
+ Column("updated_at", DateTime),
186
+ )
187
+
188
+ # Subscriptions table
189
+ Table(
190
+ "payplus_subscriptions",
191
+ metadata,
192
+ Column("id", String(50), primary_key=True),
193
+ Column("customer_id", String(50), ForeignKey("payplus_customers.id")),
194
+ Column("tier_id", String(50), ForeignKey("payplus_tiers.id")),
195
+ Column("payplus_recurring_uid", String(100)),
196
+ Column("payment_method_id", String(50)),
197
+ Column("status", String(30), default="incomplete"),
198
+ Column("amount", Numeric(10, 2), nullable=False),
199
+ Column("currency", String(3), default="ILS"),
200
+ Column("billing_cycle", String(20), default="monthly"),
201
+ Column("trial_start", DateTime),
202
+ Column("trial_end", DateTime),
203
+ Column("current_period_start", DateTime),
204
+ Column("current_period_end", DateTime),
205
+ Column("cancel_at_period_end", Boolean, default=False),
206
+ Column("canceled_at", DateTime),
207
+ Column("ended_at", DateTime),
208
+ Column("cancellation_reason", Text),
209
+ Column("invoice_count", Integer, default=0),
210
+ Column("failed_payment_count", Integer, default=0),
211
+ Column("metadata", JSON, default={}),
212
+ Column("created_at", DateTime),
213
+ Column("updated_at", DateTime),
214
+ )
215
+
216
+ # Invoices table
217
+ Table(
218
+ "payplus_invoices",
219
+ metadata,
220
+ Column("id", String(50), primary_key=True),
221
+ Column("number", String(50)),
222
+ Column("customer_id", String(50), ForeignKey("payplus_customers.id")),
223
+ Column("subscription_id", String(50), ForeignKey("payplus_subscriptions.id")),
224
+ Column("status", String(20), default="draft"),
225
+ Column("items", JSON, default=[]),
226
+ Column("subtotal", Numeric(10, 2), default=0),
227
+ Column("tax", Numeric(10, 2), default=0),
228
+ Column("tax_percent", Numeric(5, 2)),
229
+ Column("total", Numeric(10, 2), default=0),
230
+ Column("amount_due", Numeric(10, 2), default=0),
231
+ Column("amount_paid", Numeric(10, 2), default=0),
232
+ Column("currency", String(3), default="ILS"),
233
+ Column("payment_id", String(50)),
234
+ Column("billing_reason", String(50)),
235
+ Column("period_start", DateTime),
236
+ Column("period_end", DateTime),
237
+ Column("due_date", DateTime),
238
+ Column("metadata", JSON, default={}),
239
+ Column("created_at", DateTime),
240
+ Column("updated_at", DateTime),
241
+ Column("finalized_at", DateTime),
242
+ Column("paid_at", DateTime),
243
+ )
244
+
245
+ # Payments table
246
+ Table(
247
+ "payplus_payments",
248
+ metadata,
249
+ Column("id", String(50), primary_key=True),
250
+ Column("customer_id", String(50), ForeignKey("payplus_customers.id")),
251
+ Column("subscription_id", String(50), ForeignKey("payplus_subscriptions.id")),
252
+ Column("invoice_id", String(50), ForeignKey("payplus_invoices.id")),
253
+ Column("payplus_transaction_uid", String(100)),
254
+ Column("payplus_approval_number", String(50)),
255
+ Column("amount", Numeric(10, 2), nullable=False),
256
+ Column("currency", String(3), default="ILS"),
257
+ Column("payment_method_id", String(50)),
258
+ Column("card_last_four", String(4)),
259
+ Column("card_brand", String(20)),
260
+ Column("status", String(30), default="pending"),
261
+ Column("failure_code", String(50)),
262
+ Column("failure_message", Text),
263
+ Column("refunds", JSON, default=[]),
264
+ Column("amount_refunded", Numeric(10, 2), default=0),
265
+ Column("description", Text),
266
+ Column("metadata", JSON, default={}),
267
+ Column("created_at", DateTime),
268
+ Column("updated_at", DateTime),
269
+ Column("paid_at", DateTime),
270
+ )
271
+
272
+ async with self.engine.begin() as conn:
273
+ await conn.run_sync(metadata.create_all)
274
+
275
+ self._tables_created = True
276
+
277
+ async def save_customer(self, customer: Customer) -> None:
278
+ """Save a customer."""
279
+ from sqlalchemy import text
280
+
281
+ data = customer.model_dump()
282
+ data["payment_methods"] = [pm.model_dump() for pm in customer.payment_methods]
283
+
284
+ async with self.engine.begin() as conn:
285
+ await conn.execute(
286
+ text("""
287
+ INSERT INTO payplus_customers (
288
+ id, email, name, phone, company, payplus_customer_uid,
289
+ payment_methods, default_payment_method_id, status,
290
+ address_line1, address_line2, city, state, postal_code,
291
+ country, tax_id, metadata, created_at, updated_at
292
+ ) VALUES (
293
+ :id, :email, :name, :phone, :company, :payplus_customer_uid,
294
+ :payment_methods, :default_payment_method_id, :status,
295
+ :address_line1, :address_line2, :city, :state, :postal_code,
296
+ :country, :tax_id, :metadata, :created_at, :updated_at
297
+ )
298
+ ON CONFLICT (id) DO UPDATE SET
299
+ email = :email, name = :name, phone = :phone,
300
+ payment_methods = :payment_methods,
301
+ default_payment_method_id = :default_payment_method_id,
302
+ status = :status, metadata = :metadata, updated_at = :updated_at
303
+ """),
304
+ data,
305
+ )
306
+
307
+ async def get_customer(self, customer_id: str) -> Optional[Customer]:
308
+ """Get a customer by ID."""
309
+ from sqlalchemy import text
310
+
311
+ async with self.engine.connect() as conn:
312
+ result = await conn.execute(
313
+ text("SELECT * FROM payplus_customers WHERE id = :id"),
314
+ {"id": customer_id},
315
+ )
316
+ row = result.fetchone()
317
+ if row:
318
+ return Customer(**dict(row._mapping))
319
+ return None
320
+
321
+ async def get_customer_by_email(self, email: str) -> Optional[Customer]:
322
+ """Get a customer by email."""
323
+ from sqlalchemy import text
324
+
325
+ async with self.engine.connect() as conn:
326
+ result = await conn.execute(
327
+ text("SELECT * FROM payplus_customers WHERE email = :email"),
328
+ {"email": email},
329
+ )
330
+ row = result.fetchone()
331
+ if row:
332
+ return Customer(**dict(row._mapping))
333
+ return None
334
+
335
+ async def save_tier(self, tier: Tier) -> None:
336
+ """Save a tier."""
337
+ # Implementation similar to save_customer
338
+ pass
339
+
340
+ async def get_tier(self, tier_id: str) -> Optional[Tier]:
341
+ """Get a tier by ID."""
342
+ # Implementation similar to get_customer
343
+ pass
344
+
345
+ async def list_tiers(self, active_only: bool = True) -> list[Tier]:
346
+ """List all tiers."""
347
+ # Implementation
348
+ return []
349
+
350
+ async def save_subscription(self, subscription: Subscription) -> None:
351
+ """Save a subscription."""
352
+ pass
353
+
354
+ async def get_subscription(self, subscription_id: str) -> Optional[Subscription]:
355
+ """Get a subscription by ID."""
356
+ pass
357
+
358
+ async def list_subscriptions_by_customer(
359
+ self,
360
+ customer_id: str,
361
+ active_only: bool = False,
362
+ ) -> list[Subscription]:
363
+ """List subscriptions for a customer."""
364
+ return []
365
+
366
+ async def save_invoice(self, invoice: Invoice) -> None:
367
+ """Save an invoice."""
368
+ pass
369
+
370
+ async def get_invoice(self, invoice_id: str) -> Optional[Invoice]:
371
+ """Get an invoice by ID."""
372
+ pass
373
+
374
+ async def list_invoices_by_customer(
375
+ self,
376
+ customer_id: str,
377
+ limit: int = 100,
378
+ ) -> list[Invoice]:
379
+ """List invoices for a customer."""
380
+ return []
381
+
382
+ async def save_payment(self, payment: Payment) -> None:
383
+ """Save a payment."""
384
+ pass
385
+
386
+ async def get_payment(self, payment_id: str) -> Optional[Payment]:
387
+ """Get a payment by ID."""
388
+ pass
389
+
390
+
391
+ class MongoDBStorage(StorageBackend):
392
+ """
393
+ MongoDB storage backend using Motor.
394
+
395
+ Usage:
396
+ from motor.motor_asyncio import AsyncIOMotorClient
397
+ from payplus.subscriptions.storage import MongoDBStorage
398
+
399
+ client = AsyncIOMotorClient("mongodb://...")
400
+ db = client.your_database
401
+ storage = MongoDBStorage(db)
402
+ """
403
+
404
+ def __init__(self, database: Any, collection_prefix: str = "payplus_"):
405
+ """
406
+ Initialize MongoDB storage.
407
+
408
+ Args:
409
+ database: Motor database instance
410
+ collection_prefix: Prefix for collection names
411
+ """
412
+ self.db = database
413
+ self.prefix = collection_prefix
414
+
415
+ @property
416
+ def customers(self) -> Any:
417
+ return self.db[f"{self.prefix}customers"]
418
+
419
+ @property
420
+ def tiers(self) -> Any:
421
+ return self.db[f"{self.prefix}tiers"]
422
+
423
+ @property
424
+ def subscriptions(self) -> Any:
425
+ return self.db[f"{self.prefix}subscriptions"]
426
+
427
+ @property
428
+ def invoices(self) -> Any:
429
+ return self.db[f"{self.prefix}invoices"]
430
+
431
+ @property
432
+ def payments(self) -> Any:
433
+ return self.db[f"{self.prefix}payments"]
434
+
435
+ async def create_indexes(self) -> None:
436
+ """Create MongoDB indexes."""
437
+ await self.customers.create_index("email", unique=True)
438
+ await self.subscriptions.create_index("customer_id")
439
+ await self.subscriptions.create_index("status")
440
+ await self.subscriptions.create_index("current_period_end")
441
+ await self.invoices.create_index("customer_id")
442
+ await self.invoices.create_index("subscription_id")
443
+ await self.payments.create_index("customer_id")
444
+ await self.payments.create_index("invoice_id")
445
+
446
+ async def save_customer(self, customer: Customer) -> None:
447
+ """Save a customer."""
448
+ data = customer.model_dump()
449
+ data["_id"] = data.pop("id")
450
+ await self.customers.replace_one({"_id": data["_id"]}, data, upsert=True)
451
+
452
+ async def get_customer(self, customer_id: str) -> Optional[Customer]:
453
+ """Get a customer by ID."""
454
+ doc = await self.customers.find_one({"_id": customer_id})
455
+ if doc:
456
+ doc["id"] = doc.pop("_id")
457
+ return Customer(**doc)
458
+ return None
459
+
460
+ async def get_customer_by_email(self, email: str) -> Optional[Customer]:
461
+ """Get a customer by email."""
462
+ doc = await self.customers.find_one({"email": email})
463
+ if doc:
464
+ doc["id"] = doc.pop("_id")
465
+ return Customer(**doc)
466
+ return None
467
+
468
+ async def save_tier(self, tier: Tier) -> None:
469
+ """Save a tier."""
470
+ data = tier.model_dump()
471
+ data["_id"] = data.pop("id")
472
+ # Convert Decimal to float for MongoDB
473
+ data["price"] = float(data["price"])
474
+ await self.tiers.replace_one({"_id": data["_id"]}, data, upsert=True)
475
+
476
+ async def get_tier(self, tier_id: str) -> Optional[Tier]:
477
+ """Get a tier by ID."""
478
+ doc = await self.tiers.find_one({"_id": tier_id})
479
+ if doc:
480
+ doc["id"] = doc.pop("_id")
481
+ return Tier(**doc)
482
+ return None
483
+
484
+ async def list_tiers(self, active_only: bool = True) -> list[Tier]:
485
+ """List all tiers."""
486
+ query = {"is_active": True} if active_only else {}
487
+ cursor = self.tiers.find(query).sort("display_order", 1)
488
+ tiers = []
489
+ async for doc in cursor:
490
+ doc["id"] = doc.pop("_id")
491
+ tiers.append(Tier(**doc))
492
+ return tiers
493
+
494
+ async def save_subscription(self, subscription: Subscription) -> None:
495
+ """Save a subscription."""
496
+ data = subscription.model_dump()
497
+ data["_id"] = data.pop("id")
498
+ data["amount"] = float(data["amount"])
499
+ await self.subscriptions.replace_one({"_id": data["_id"]}, data, upsert=True)
500
+
501
+ async def get_subscription(self, subscription_id: str) -> Optional[Subscription]:
502
+ """Get a subscription by ID."""
503
+ doc = await self.subscriptions.find_one({"_id": subscription_id})
504
+ if doc:
505
+ doc["id"] = doc.pop("_id")
506
+ return Subscription(**doc)
507
+ return None
508
+
509
+ async def list_subscriptions_by_customer(
510
+ self,
511
+ customer_id: str,
512
+ active_only: bool = False,
513
+ ) -> list[Subscription]:
514
+ """List subscriptions for a customer."""
515
+ query: dict[str, Any] = {"customer_id": customer_id}
516
+ if active_only:
517
+ query["status"] = {"$in": ["active", "trialing"]}
518
+
519
+ cursor = self.subscriptions.find(query).sort("created_at", -1)
520
+ subscriptions = []
521
+ async for doc in cursor:
522
+ doc["id"] = doc.pop("_id")
523
+ subscriptions.append(Subscription(**doc))
524
+ return subscriptions
525
+
526
+ async def save_invoice(self, invoice: Invoice) -> None:
527
+ """Save an invoice."""
528
+ data = invoice.model_dump()
529
+ data["_id"] = data.pop("id")
530
+ # Convert Decimals
531
+ for key in ["subtotal", "tax", "total", "amount_due", "amount_paid", "amount_remaining"]:
532
+ if key in data:
533
+ data[key] = float(data[key])
534
+ await self.invoices.replace_one({"_id": data["_id"]}, data, upsert=True)
535
+
536
+ async def get_invoice(self, invoice_id: str) -> Optional[Invoice]:
537
+ """Get an invoice by ID."""
538
+ doc = await self.invoices.find_one({"_id": invoice_id})
539
+ if doc:
540
+ doc["id"] = doc.pop("_id")
541
+ return Invoice(**doc)
542
+ return None
543
+
544
+ async def list_invoices_by_customer(
545
+ self,
546
+ customer_id: str,
547
+ limit: int = 100,
548
+ ) -> list[Invoice]:
549
+ """List invoices for a customer."""
550
+ cursor = self.invoices.find({"customer_id": customer_id}).sort("created_at", -1).limit(limit)
551
+ invoices = []
552
+ async for doc in cursor:
553
+ doc["id"] = doc.pop("_id")
554
+ invoices.append(Invoice(**doc))
555
+ return invoices
556
+
557
+ async def save_payment(self, payment: Payment) -> None:
558
+ """Save a payment."""
559
+ data = payment.model_dump()
560
+ data["_id"] = data.pop("id")
561
+ data["amount"] = float(data["amount"])
562
+ data["amount_refunded"] = float(data["amount_refunded"])
563
+ await self.payments.replace_one({"_id": data["_id"]}, data, upsert=True)
564
+
565
+ async def get_payment(self, payment_id: str) -> Optional[Payment]:
566
+ """Get a payment by ID."""
567
+ doc = await self.payments.find_one({"_id": payment_id})
568
+ if doc:
569
+ doc["id"] = doc.pop("_id")
570
+ return Payment(**doc)
571
+ return None
@@ -0,0 +1,10 @@
1
+ """
2
+ PayPlus Webhook handling.
3
+ """
4
+
5
+ from payplus.webhooks.handler import WebhookHandler, WebhookEvent
6
+
7
+ __all__ = [
8
+ "WebhookHandler",
9
+ "WebhookEvent",
10
+ ]