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