payplus-python 0.1.2__py3-none-any.whl → 0.2.1__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/subscription_saas.py +66 -121
- payplus/__init__.py +4 -10
- payplus/api/customers.py +114 -0
- payplus/api/payment_pages.py +167 -44
- payplus/api/recurring.py +83 -0
- payplus/client.py +3 -1
- payplus/models/__init__.py +1 -8
- payplus/models/subscription.py +3 -1
- payplus/subscriptions/__init__.py +0 -2
- payplus/subscriptions/manager.py +337 -337
- payplus/subscriptions/storage.py +100 -251
- payplus/webhooks/handler.py +154 -162
- payplus_python-0.2.1.dist-info/METADATA +487 -0
- payplus_python-0.2.1.dist-info/RECORD +29 -0
- {payplus_python-0.1.2.dist-info → payplus_python-0.2.1.dist-info}/WHEEL +1 -1
- tests/test_models.py +116 -171
- payplus/models/invoice.py +0 -242
- payplus/models/payment.py +0 -179
- payplus/subscriptions/billing.py +0 -231
- payplus_python-0.1.2.dist-info/METADATA +0 -446
- payplus_python-0.1.2.dist-info/RECORD +0 -31
- {payplus_python-0.1.2.dist-info → payplus_python-0.2.1.dist-info}/licenses/LICENSE +0 -0
- {payplus_python-0.1.2.dist-info → payplus_python-0.2.1.dist-info}/top_level.txt +0 -0
examples/subscription_saas.py
CHANGED
|
@@ -8,7 +8,7 @@ from decimal import Decimal
|
|
|
8
8
|
|
|
9
9
|
from payplus import PayPlus, SubscriptionManager
|
|
10
10
|
from payplus.subscriptions.storage import MongoDBStorage
|
|
11
|
-
from payplus.
|
|
11
|
+
from payplus.webhooks import WebhookHandler
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
async def main():
|
|
@@ -18,7 +18,7 @@ async def main():
|
|
|
18
18
|
secret_key=os.environ.get("PAYPLUS_SECRET_KEY", "your_secret_key"),
|
|
19
19
|
sandbox=True,
|
|
20
20
|
)
|
|
21
|
-
|
|
21
|
+
|
|
22
22
|
# Setup MongoDB storage
|
|
23
23
|
try:
|
|
24
24
|
from motor.motor_asyncio import AsyncIOMotorClient
|
|
@@ -28,33 +28,19 @@ async def main():
|
|
|
28
28
|
except ImportError:
|
|
29
29
|
print("MongoDB not available, using in-memory storage")
|
|
30
30
|
storage = None
|
|
31
|
-
|
|
31
|
+
|
|
32
32
|
# Create subscription manager
|
|
33
33
|
manager = SubscriptionManager(client, storage)
|
|
34
|
-
|
|
34
|
+
|
|
35
35
|
# Register event handlers
|
|
36
|
-
manager.on("subscription.created", lambda s: print(f"
|
|
37
|
-
manager.on("subscription.activated", lambda s: print(f"
|
|
38
|
-
manager.on("
|
|
39
|
-
manager.on("
|
|
40
|
-
|
|
36
|
+
manager.on("subscription.created", lambda s: print(f"Subscription created: {s.id}"))
|
|
37
|
+
manager.on("subscription.activated", lambda s: print(f"Subscription activated: {s.id}"))
|
|
38
|
+
manager.on("subscription.renewed", lambda s: print(f"Subscription renewed: {s.id}"))
|
|
39
|
+
manager.on("subscription.payment_failed", lambda s: print(f"Payment failed for: {s.id}"))
|
|
40
|
+
|
|
41
41
|
# ==================== Setup Pricing Tiers ====================
|
|
42
|
-
print("\
|
|
43
|
-
|
|
44
|
-
# Free tier
|
|
45
|
-
free_tier = await manager.create_tier(
|
|
46
|
-
tier_id="free",
|
|
47
|
-
name="Free",
|
|
48
|
-
price=Decimal("0"),
|
|
49
|
-
features=[
|
|
50
|
-
{"feature_id": "projects", "name": "Projects", "included_quantity": 3},
|
|
51
|
-
{"feature_id": "storage_gb", "name": "Storage (GB)", "included_quantity": 1},
|
|
52
|
-
{"feature_id": "team_members", "name": "Team Members", "included_quantity": 1},
|
|
53
|
-
],
|
|
54
|
-
)
|
|
55
|
-
print(f" - {free_tier.name}: ₪{free_tier.price}/month")
|
|
56
|
-
|
|
57
|
-
# Basic tier
|
|
42
|
+
print("\nCreating pricing tiers...")
|
|
43
|
+
|
|
58
44
|
basic_tier = await manager.create_tier(
|
|
59
45
|
tier_id="basic",
|
|
60
46
|
name="Basic",
|
|
@@ -64,141 +50,100 @@ async def main():
|
|
|
64
50
|
{"feature_id": "projects", "name": "Projects", "included_quantity": 10},
|
|
65
51
|
{"feature_id": "storage_gb", "name": "Storage (GB)", "included_quantity": 10},
|
|
66
52
|
{"feature_id": "team_members", "name": "Team Members", "included_quantity": 5},
|
|
67
|
-
{"feature_id": "api_access", "name": "API Access"},
|
|
68
53
|
],
|
|
69
54
|
)
|
|
70
|
-
print(f" - {basic_tier.name}:
|
|
71
|
-
|
|
72
|
-
# Pro tier
|
|
55
|
+
print(f" - {basic_tier.name}: ILS {basic_tier.price}/month (7-day trial)")
|
|
56
|
+
|
|
73
57
|
pro_tier = await manager.create_tier(
|
|
74
58
|
tier_id="pro",
|
|
75
59
|
name="Pro",
|
|
76
60
|
price=Decimal("79"),
|
|
77
61
|
trial_days=14,
|
|
78
62
|
is_popular=True,
|
|
79
|
-
annual_discount_percent=Decimal("20"),
|
|
80
63
|
features=[
|
|
81
|
-
{"feature_id": "projects", "name": "Projects", "included_quantity": None},
|
|
64
|
+
{"feature_id": "projects", "name": "Projects", "included_quantity": None},
|
|
82
65
|
{"feature_id": "storage_gb", "name": "Storage (GB)", "included_quantity": 100},
|
|
83
66
|
{"feature_id": "team_members", "name": "Team Members", "included_quantity": 20},
|
|
84
67
|
{"feature_id": "api_access", "name": "API Access"},
|
|
85
68
|
{"feature_id": "priority_support", "name": "Priority Support"},
|
|
86
|
-
{"feature_id": "custom_domain", "name": "Custom Domain"},
|
|
87
|
-
],
|
|
88
|
-
)
|
|
89
|
-
print(f" - {pro_tier.name}: ₪{pro_tier.price}/month (14-day trial, 20% annual discount)")
|
|
90
|
-
|
|
91
|
-
# Enterprise tier
|
|
92
|
-
enterprise_tier = await manager.create_tier(
|
|
93
|
-
tier_id="enterprise",
|
|
94
|
-
name="Enterprise",
|
|
95
|
-
price=Decimal("199"),
|
|
96
|
-
annual_discount_percent=Decimal("25"),
|
|
97
|
-
features=[
|
|
98
|
-
{"feature_id": "projects", "name": "Projects", "included_quantity": None},
|
|
99
|
-
{"feature_id": "storage_gb", "name": "Storage (GB)", "included_quantity": None},
|
|
100
|
-
{"feature_id": "team_members", "name": "Team Members", "included_quantity": None},
|
|
101
|
-
{"feature_id": "api_access", "name": "API Access"},
|
|
102
|
-
{"feature_id": "priority_support", "name": "Priority Support"},
|
|
103
|
-
{"feature_id": "custom_domain", "name": "Custom Domain"},
|
|
104
|
-
{"feature_id": "sso", "name": "SSO Integration"},
|
|
105
|
-
{"feature_id": "dedicated_support", "name": "Dedicated Account Manager"},
|
|
106
69
|
],
|
|
107
70
|
)
|
|
108
|
-
print(f" - {
|
|
109
|
-
|
|
71
|
+
print(f" - {pro_tier.name}: ILS {pro_tier.price}/month (14-day trial)")
|
|
72
|
+
|
|
110
73
|
# ==================== Create Customer ====================
|
|
111
|
-
print("\
|
|
112
|
-
|
|
74
|
+
print("\nCreating customer...")
|
|
75
|
+
|
|
113
76
|
customer = await manager.create_customer(
|
|
114
77
|
email="demo@example.com",
|
|
115
78
|
name="Demo User",
|
|
116
|
-
metadata={"source": "signup_form", "campaign": "launch_2024"},
|
|
117
79
|
)
|
|
118
80
|
print(f" Customer ID: {customer.id}")
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
# ==================== Add Payment Method ====================
|
|
122
|
-
print("\n💳 Adding payment method...")
|
|
123
|
-
|
|
124
|
-
# In production, this token comes from a PayPlus payment page
|
|
125
|
-
# For demo, we'll simulate with a fake token
|
|
126
|
-
demo_token = "demo_card_token_xxx"
|
|
127
|
-
|
|
128
|
-
payment_method = await manager.add_payment_method(
|
|
129
|
-
customer_id=customer.id,
|
|
130
|
-
token=demo_token,
|
|
131
|
-
card_brand="Visa",
|
|
132
|
-
last_four="4242",
|
|
133
|
-
expiry_month="12",
|
|
134
|
-
expiry_year="2028",
|
|
135
|
-
)
|
|
136
|
-
print(f" Payment Method: {payment_method.card_brand} ****{payment_method.last_four}")
|
|
137
|
-
|
|
81
|
+
|
|
138
82
|
# ==================== Create Subscription ====================
|
|
139
|
-
print("\
|
|
140
|
-
|
|
83
|
+
print("\nCreating Pro subscription (generates payment link)...")
|
|
84
|
+
|
|
141
85
|
subscription = await manager.create_subscription(
|
|
142
86
|
customer_id=customer.id,
|
|
143
87
|
tier_id="pro",
|
|
88
|
+
callback_url="https://example.com/webhooks/payplus",
|
|
89
|
+
success_url="https://example.com/success",
|
|
90
|
+
failure_url="https://example.com/failure",
|
|
144
91
|
)
|
|
145
|
-
|
|
92
|
+
|
|
146
93
|
print(f" Subscription ID: {subscription.id}")
|
|
147
|
-
print(f" Status: {subscription.status}")
|
|
148
|
-
print(f" Amount:
|
|
149
|
-
print(f"
|
|
150
|
-
print(f"
|
|
151
|
-
|
|
94
|
+
print(f" Status: {subscription.status}") # INCOMPLETE until customer pays
|
|
95
|
+
print(f" Amount: ILS {subscription.amount}/month")
|
|
96
|
+
print(f" Payment link: {subscription.payment_page_link}")
|
|
97
|
+
print(f" -> Redirect customer to this link to complete payment")
|
|
98
|
+
|
|
99
|
+
# ==================== Webhook Handling ====================
|
|
100
|
+
# In production, this happens when PayPlus sends a webhook after the customer pays.
|
|
101
|
+
# The webhook handler + manager.handle_webhook_event() updates the subscription:
|
|
102
|
+
#
|
|
103
|
+
# webhook_handler = WebhookHandler(client)
|
|
104
|
+
#
|
|
105
|
+
# @webhook_handler.on("payment.success")
|
|
106
|
+
# async def on_payment(event):
|
|
107
|
+
# sub = await manager.handle_webhook_event(event)
|
|
108
|
+
# if sub:
|
|
109
|
+
# print(f"Subscription {sub.id} is now {sub.status}")
|
|
110
|
+
#
|
|
111
|
+
# @webhook_handler.on("recurring.charged")
|
|
112
|
+
# async def on_renewal(event):
|
|
113
|
+
# await manager.handle_webhook_event(event)
|
|
114
|
+
#
|
|
115
|
+
# @webhook_handler.on("recurring.failed")
|
|
116
|
+
# async def on_failure(event):
|
|
117
|
+
# await manager.handle_webhook_event(event)
|
|
118
|
+
|
|
152
119
|
# ==================== Subscription Operations ====================
|
|
153
|
-
print("\
|
|
154
|
-
|
|
155
|
-
#
|
|
156
|
-
print(
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
print(f" New tier: {subscription.tier_id}")
|
|
163
|
-
print(f" New amount: ₪{subscription.amount}/month")
|
|
164
|
-
|
|
165
|
-
# Pause subscription
|
|
166
|
-
print("\n ⏸️ Pausing subscription...")
|
|
120
|
+
print("\nSubscription operations...")
|
|
121
|
+
|
|
122
|
+
# Upgrade to basic (while still incomplete, for demo)
|
|
123
|
+
print("\n Upgrading tier...")
|
|
124
|
+
subscription = await manager.change_tier(subscription.id, "basic")
|
|
125
|
+
print(f" New tier: {subscription.tier_id}, amount: ILS {subscription.amount}")
|
|
126
|
+
|
|
127
|
+
# Pause
|
|
128
|
+
print("\n Pausing subscription...")
|
|
167
129
|
subscription = await manager.pause_subscription(subscription.id)
|
|
168
130
|
print(f" Status: {subscription.status}")
|
|
169
|
-
|
|
170
|
-
# Resume
|
|
171
|
-
print("\n
|
|
131
|
+
|
|
132
|
+
# Resume
|
|
133
|
+
print("\n Resuming subscription...")
|
|
172
134
|
subscription = await manager.resume_subscription(subscription.id)
|
|
173
135
|
print(f" Status: {subscription.status}")
|
|
174
|
-
|
|
136
|
+
|
|
175
137
|
# Cancel at period end
|
|
176
|
-
print("\n
|
|
138
|
+
print("\n Scheduling cancellation...")
|
|
177
139
|
subscription = await manager.cancel_subscription(
|
|
178
140
|
subscription.id,
|
|
179
141
|
at_period_end=True,
|
|
180
142
|
reason="Demo completed",
|
|
181
143
|
)
|
|
182
|
-
print(f" Will cancel at: {subscription.current_period_end}")
|
|
183
144
|
print(f" Cancellation reason: {subscription.cancellation_reason}")
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
print("\n💵 Billing service...")
|
|
187
|
-
|
|
188
|
-
billing = BillingService(manager)
|
|
189
|
-
|
|
190
|
-
# In production, these would run on a schedule
|
|
191
|
-
print(" Processing due renewals...")
|
|
192
|
-
renewed = await billing.process_due_renewals()
|
|
193
|
-
print(f" Renewed: {len(renewed)} subscriptions")
|
|
194
|
-
|
|
195
|
-
print(" Processing trial endings...")
|
|
196
|
-
converted = await billing.process_trial_endings()
|
|
197
|
-
print(f" Converted: {len(converted)} trials")
|
|
198
|
-
|
|
199
|
-
print("\n✅ Demo completed!")
|
|
200
|
-
|
|
201
|
-
# Cleanup
|
|
145
|
+
|
|
146
|
+
print("\nDemo completed!")
|
|
202
147
|
client.close()
|
|
203
148
|
|
|
204
149
|
|
payplus/__init__.py
CHANGED
|
@@ -3,18 +3,16 @@ PayPlus Python SDK - Payment gateway integration with subscription management fo
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
from payplus.client import PayPlus
|
|
6
|
-
from payplus.
|
|
6
|
+
from payplus.models.customer import Customer
|
|
7
7
|
from payplus.models.subscription import (
|
|
8
|
+
BillingCycle,
|
|
8
9
|
Subscription,
|
|
9
10
|
SubscriptionStatus,
|
|
10
|
-
BillingCycle,
|
|
11
11
|
)
|
|
12
|
-
from payplus.models.customer import Customer
|
|
13
|
-
from payplus.models.payment import Payment, PaymentStatus
|
|
14
|
-
from payplus.models.invoice import Invoice, InvoiceStatus
|
|
15
12
|
from payplus.models.tier import Tier
|
|
13
|
+
from payplus.subscriptions.manager import SubscriptionManager
|
|
16
14
|
|
|
17
|
-
__version__ = "0.1
|
|
15
|
+
__version__ = "0.2.1"
|
|
18
16
|
__all__ = [
|
|
19
17
|
"PayPlus",
|
|
20
18
|
"SubscriptionManager",
|
|
@@ -22,9 +20,5 @@ __all__ = [
|
|
|
22
20
|
"SubscriptionStatus",
|
|
23
21
|
"BillingCycle",
|
|
24
22
|
"Customer",
|
|
25
|
-
"Payment",
|
|
26
|
-
"PaymentStatus",
|
|
27
|
-
"Invoice",
|
|
28
|
-
"InvoiceStatus",
|
|
29
23
|
"Tier",
|
|
30
24
|
]
|
payplus/api/customers.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PayPlus Customers API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from payplus.api.base import BaseAPI
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CustomersAPI(BaseAPI):
|
|
13
|
+
"""
|
|
14
|
+
Customers API for creating and managing customers on PayPlus.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def add(
|
|
18
|
+
self,
|
|
19
|
+
customer_name: str,
|
|
20
|
+
email: str,
|
|
21
|
+
phone: Optional[str] = None,
|
|
22
|
+
vat_number: Optional[int] = None,
|
|
23
|
+
paying_vat: bool = True,
|
|
24
|
+
customer_number: Optional[str] = None,
|
|
25
|
+
notes: Optional[str] = None,
|
|
26
|
+
contacts: Optional[list[dict[str, Any]]] = None,
|
|
27
|
+
business_address: Optional[str] = None,
|
|
28
|
+
business_city: Optional[str] = None,
|
|
29
|
+
business_postal_code: Optional[str] = None,
|
|
30
|
+
business_country_iso: str = "IL",
|
|
31
|
+
subject_code: Optional[str] = None,
|
|
32
|
+
communication_email: Optional[str] = None,
|
|
33
|
+
**kwargs: Any,
|
|
34
|
+
) -> dict[str, Any]:
|
|
35
|
+
"""
|
|
36
|
+
Create a new customer on PayPlus.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
customer_name: Customer name (required)
|
|
40
|
+
email: Customer email (required)
|
|
41
|
+
phone: Customer phone number
|
|
42
|
+
vat_number: Customer or company VAT number
|
|
43
|
+
paying_vat: Whether customer pays VAT
|
|
44
|
+
customer_number: Internal customer number
|
|
45
|
+
notes: Notes about the customer
|
|
46
|
+
contacts: List of contact dicts
|
|
47
|
+
business_address: Business address
|
|
48
|
+
business_city: Business city
|
|
49
|
+
business_postal_code: Business postal code
|
|
50
|
+
business_country_iso: Business country code (default: IL)
|
|
51
|
+
subject_code: External customer number from ERP
|
|
52
|
+
communication_email: Email for communication
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
API response with customer_uid in data
|
|
56
|
+
"""
|
|
57
|
+
data: dict[str, Any] = {
|
|
58
|
+
"customer_name": customer_name,
|
|
59
|
+
"email": email,
|
|
60
|
+
"paying_vat": paying_vat,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if phone is not None:
|
|
64
|
+
data["phone"] = phone
|
|
65
|
+
if vat_number is not None:
|
|
66
|
+
data["vat_number"] = vat_number
|
|
67
|
+
if customer_number is not None:
|
|
68
|
+
data["customer_number"] = customer_number
|
|
69
|
+
if notes is not None:
|
|
70
|
+
data["notes"] = notes
|
|
71
|
+
if contacts is not None:
|
|
72
|
+
data["contacts"] = contacts
|
|
73
|
+
if business_address is not None:
|
|
74
|
+
data["business_address"] = business_address
|
|
75
|
+
if business_city is not None:
|
|
76
|
+
data["business_city"] = business_city
|
|
77
|
+
if business_postal_code is not None:
|
|
78
|
+
data["business_postal_code"] = business_postal_code
|
|
79
|
+
if business_country_iso != "IL":
|
|
80
|
+
data["business_country_iso"] = business_country_iso
|
|
81
|
+
if subject_code is not None:
|
|
82
|
+
data["subject_code"] = subject_code
|
|
83
|
+
if communication_email is not None:
|
|
84
|
+
data["communication_email"] = communication_email
|
|
85
|
+
|
|
86
|
+
for key, value in kwargs.items():
|
|
87
|
+
if key not in data and value is not None:
|
|
88
|
+
data[key] = value
|
|
89
|
+
|
|
90
|
+
return self._request("POST", "Customers/Add", data)
|
|
91
|
+
|
|
92
|
+
async def async_add(
|
|
93
|
+
self,
|
|
94
|
+
customer_name: str,
|
|
95
|
+
email: str,
|
|
96
|
+
**kwargs: Any,
|
|
97
|
+
) -> dict[str, Any]:
|
|
98
|
+
"""Async version of add."""
|
|
99
|
+
data: dict[str, Any] = {
|
|
100
|
+
"customer_name": customer_name,
|
|
101
|
+
"email": email,
|
|
102
|
+
"paying_vat": kwargs.pop("paying_vat", True),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if kwargs.get("phone") is not None:
|
|
106
|
+
data["phone"] = kwargs.pop("phone")
|
|
107
|
+
if kwargs.get("vat_number") is not None:
|
|
108
|
+
data["vat_number"] = kwargs.pop("vat_number")
|
|
109
|
+
|
|
110
|
+
for key, value in kwargs.items():
|
|
111
|
+
if key not in data and value is not None:
|
|
112
|
+
data[key] = value
|
|
113
|
+
|
|
114
|
+
return await self._async_request("POST", "Customers/Add", data)
|