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,370 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Webhook handler for PayPlus IPN (Instant Payment Notification).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any, Callable, Optional
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from payplus.exceptions import WebhookError, WebhookSignatureError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WebhookEventType(str, Enum):
|
|
17
|
+
"""PayPlus webhook event types."""
|
|
18
|
+
|
|
19
|
+
# Payment events
|
|
20
|
+
PAYMENT_SUCCESS = "payment.success"
|
|
21
|
+
PAYMENT_FAILURE = "payment.failure"
|
|
22
|
+
PAYMENT_PENDING = "payment.pending"
|
|
23
|
+
|
|
24
|
+
# Recurring events
|
|
25
|
+
RECURRING_CREATED = "recurring.created"
|
|
26
|
+
RECURRING_CHARGED = "recurring.charged"
|
|
27
|
+
RECURRING_FAILED = "recurring.failed"
|
|
28
|
+
RECURRING_CANCELED = "recurring.canceled"
|
|
29
|
+
|
|
30
|
+
# Refund events
|
|
31
|
+
REFUND_SUCCESS = "refund.success"
|
|
32
|
+
REFUND_FAILURE = "refund.failure"
|
|
33
|
+
|
|
34
|
+
# Token events
|
|
35
|
+
TOKEN_CREATED = "token.created"
|
|
36
|
+
|
|
37
|
+
# Unknown
|
|
38
|
+
UNKNOWN = "unknown"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class WebhookEvent(BaseModel):
|
|
42
|
+
"""Parsed webhook event."""
|
|
43
|
+
|
|
44
|
+
id: str = Field(..., description="Event ID")
|
|
45
|
+
type: WebhookEventType = Field(default=WebhookEventType.UNKNOWN)
|
|
46
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
47
|
+
|
|
48
|
+
# PayPlus specific fields
|
|
49
|
+
transaction_uid: Optional[str] = None
|
|
50
|
+
recurring_uid: Optional[str] = None
|
|
51
|
+
page_request_uid: Optional[str] = None
|
|
52
|
+
approval_number: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
# Amount
|
|
55
|
+
amount: Optional[float] = None
|
|
56
|
+
currency: Optional[str] = None
|
|
57
|
+
|
|
58
|
+
# Status
|
|
59
|
+
status: Optional[str] = None
|
|
60
|
+
status_code: Optional[str] = None
|
|
61
|
+
status_description: Optional[str] = None
|
|
62
|
+
|
|
63
|
+
# Customer
|
|
64
|
+
customer_uid: Optional[str] = None
|
|
65
|
+
customer_email: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
# Card
|
|
68
|
+
card_uid: Optional[str] = None
|
|
69
|
+
card_brand: Optional[str] = None
|
|
70
|
+
card_last_four: Optional[str] = None
|
|
71
|
+
|
|
72
|
+
# Custom fields
|
|
73
|
+
more_info: Optional[str] = None
|
|
74
|
+
more_info_1: Optional[str] = None
|
|
75
|
+
more_info_2: Optional[str] = None
|
|
76
|
+
|
|
77
|
+
# Raw data
|
|
78
|
+
raw_data: dict[str, Any] = Field(default_factory=dict)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class WebhookHandler:
|
|
82
|
+
"""
|
|
83
|
+
Handler for PayPlus IPN (webhook) notifications.
|
|
84
|
+
|
|
85
|
+
Usage with FastAPI:
|
|
86
|
+
from fastapi import FastAPI, Request, HTTPException
|
|
87
|
+
from payplus import PayPlus
|
|
88
|
+
from payplus.webhooks import WebhookHandler
|
|
89
|
+
|
|
90
|
+
app = FastAPI()
|
|
91
|
+
client = PayPlus(api_key="...", secret_key="...")
|
|
92
|
+
webhook_handler = WebhookHandler(client)
|
|
93
|
+
|
|
94
|
+
@webhook_handler.on("payment.success")
|
|
95
|
+
async def handle_payment_success(event: WebhookEvent):
|
|
96
|
+
print(f"Payment succeeded: {event.transaction_uid}")
|
|
97
|
+
|
|
98
|
+
@app.post("/webhooks/payplus")
|
|
99
|
+
async def payplus_webhook(request: Request):
|
|
100
|
+
payload = await request.body()
|
|
101
|
+
signature = request.headers.get("X-PayPlus-Signature", "")
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
event = webhook_handler.handle(payload, signature)
|
|
105
|
+
return {"received": True}
|
|
106
|
+
except WebhookSignatureError:
|
|
107
|
+
raise HTTPException(status_code=400, detail="Invalid signature")
|
|
108
|
+
|
|
109
|
+
Usage with Flask:
|
|
110
|
+
from flask import Flask, request
|
|
111
|
+
from payplus import PayPlus
|
|
112
|
+
from payplus.webhooks import WebhookHandler
|
|
113
|
+
|
|
114
|
+
app = Flask(__name__)
|
|
115
|
+
client = PayPlus(api_key="...", secret_key="...")
|
|
116
|
+
webhook_handler = WebhookHandler(client)
|
|
117
|
+
|
|
118
|
+
@app.route("/webhooks/payplus", methods=["POST"])
|
|
119
|
+
def payplus_webhook():
|
|
120
|
+
payload = request.get_data()
|
|
121
|
+
signature = request.headers.get("X-PayPlus-Signature", "")
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
event = webhook_handler.handle(payload, signature)
|
|
125
|
+
return {"received": True}, 200
|
|
126
|
+
except WebhookSignatureError:
|
|
127
|
+
return {"error": "Invalid signature"}, 400
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
client: Any,
|
|
133
|
+
verify_signature: bool = True,
|
|
134
|
+
):
|
|
135
|
+
"""
|
|
136
|
+
Initialize webhook handler.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
client: PayPlus client for signature verification
|
|
140
|
+
verify_signature: Whether to verify webhook signatures
|
|
141
|
+
"""
|
|
142
|
+
self.client = client
|
|
143
|
+
self.verify_signature = verify_signature
|
|
144
|
+
self._handlers: dict[str, list[Callable[[WebhookEvent], Any]]] = {}
|
|
145
|
+
|
|
146
|
+
def on(self, event_type: str) -> Callable:
|
|
147
|
+
"""
|
|
148
|
+
Decorator to register an event handler.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
event_type: Event type to handle (e.g., "payment.success")
|
|
152
|
+
"""
|
|
153
|
+
def decorator(func: Callable[[WebhookEvent], Any]) -> Callable:
|
|
154
|
+
if event_type not in self._handlers:
|
|
155
|
+
self._handlers[event_type] = []
|
|
156
|
+
self._handlers[event_type].append(func)
|
|
157
|
+
return func
|
|
158
|
+
return decorator
|
|
159
|
+
|
|
160
|
+
def register_handler(
|
|
161
|
+
self,
|
|
162
|
+
event_type: str,
|
|
163
|
+
handler: Callable[[WebhookEvent], Any],
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Register an event handler programmatically."""
|
|
166
|
+
if event_type not in self._handlers:
|
|
167
|
+
self._handlers[event_type] = []
|
|
168
|
+
self._handlers[event_type].append(handler)
|
|
169
|
+
|
|
170
|
+
def handle(
|
|
171
|
+
self,
|
|
172
|
+
payload: bytes,
|
|
173
|
+
signature: Optional[str] = None,
|
|
174
|
+
) -> WebhookEvent:
|
|
175
|
+
"""
|
|
176
|
+
Handle an incoming webhook.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
payload: Raw request body
|
|
180
|
+
signature: Signature from X-PayPlus-Signature header
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Parsed webhook event
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
WebhookSignatureError: If signature verification fails
|
|
187
|
+
WebhookError: If payload parsing fails
|
|
188
|
+
"""
|
|
189
|
+
# Verify signature if enabled
|
|
190
|
+
if self.verify_signature and signature:
|
|
191
|
+
if not self.client.verify_webhook_signature(payload, signature):
|
|
192
|
+
raise WebhookSignatureError("Invalid webhook signature")
|
|
193
|
+
|
|
194
|
+
# Parse payload
|
|
195
|
+
try:
|
|
196
|
+
import json
|
|
197
|
+
data = json.loads(payload)
|
|
198
|
+
except json.JSONDecodeError as e:
|
|
199
|
+
raise WebhookError(f"Invalid JSON payload: {e}")
|
|
200
|
+
|
|
201
|
+
# Parse event
|
|
202
|
+
event = self._parse_event(data)
|
|
203
|
+
|
|
204
|
+
# Call handlers
|
|
205
|
+
self._dispatch(event)
|
|
206
|
+
|
|
207
|
+
return event
|
|
208
|
+
|
|
209
|
+
async def handle_async(
|
|
210
|
+
self,
|
|
211
|
+
payload: bytes,
|
|
212
|
+
signature: Optional[str] = None,
|
|
213
|
+
) -> WebhookEvent:
|
|
214
|
+
"""
|
|
215
|
+
Handle an incoming webhook asynchronously.
|
|
216
|
+
|
|
217
|
+
Same as handle() but calls async handlers.
|
|
218
|
+
"""
|
|
219
|
+
if self.verify_signature and signature:
|
|
220
|
+
if not self.client.verify_webhook_signature(payload, signature):
|
|
221
|
+
raise WebhookSignatureError("Invalid webhook signature")
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
import json
|
|
225
|
+
data = json.loads(payload)
|
|
226
|
+
except json.JSONDecodeError as e:
|
|
227
|
+
raise WebhookError(f"Invalid JSON payload: {e}")
|
|
228
|
+
|
|
229
|
+
event = self._parse_event(data)
|
|
230
|
+
await self._dispatch_async(event)
|
|
231
|
+
|
|
232
|
+
return event
|
|
233
|
+
|
|
234
|
+
def _parse_event(self, data: dict[str, Any]) -> WebhookEvent:
|
|
235
|
+
"""Parse raw webhook data into an event."""
|
|
236
|
+
import uuid
|
|
237
|
+
|
|
238
|
+
# Determine event type based on data
|
|
239
|
+
event_type = self._determine_event_type(data)
|
|
240
|
+
|
|
241
|
+
# Extract common fields
|
|
242
|
+
results = data.get("results", data.get("data", data))
|
|
243
|
+
|
|
244
|
+
return WebhookEvent(
|
|
245
|
+
id=data.get("id", str(uuid.uuid4())),
|
|
246
|
+
type=event_type,
|
|
247
|
+
transaction_uid=results.get("transaction_uid"),
|
|
248
|
+
recurring_uid=results.get("recurring_uid"),
|
|
249
|
+
page_request_uid=results.get("page_request_uid"),
|
|
250
|
+
approval_number=results.get("approval_number"),
|
|
251
|
+
amount=results.get("amount"),
|
|
252
|
+
currency=results.get("currency_code"),
|
|
253
|
+
status=results.get("status"),
|
|
254
|
+
status_code=results.get("status_code"),
|
|
255
|
+
status_description=results.get("status_description"),
|
|
256
|
+
customer_uid=results.get("customer", {}).get("customer_uid") if isinstance(results.get("customer"), dict) else results.get("customer_uid"),
|
|
257
|
+
customer_email=results.get("customer", {}).get("email") if isinstance(results.get("customer"), dict) else results.get("customer_email"),
|
|
258
|
+
card_uid=results.get("card_uid"),
|
|
259
|
+
card_brand=results.get("card_brand"),
|
|
260
|
+
card_last_four=results.get("four_digits"),
|
|
261
|
+
more_info=results.get("more_info"),
|
|
262
|
+
more_info_1=results.get("more_info_1"),
|
|
263
|
+
more_info_2=results.get("more_info_2"),
|
|
264
|
+
raw_data=data,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def _determine_event_type(self, data: dict[str, Any]) -> WebhookEventType:
|
|
268
|
+
"""Determine the event type from webhook data."""
|
|
269
|
+
results = data.get("results", data.get("data", data))
|
|
270
|
+
status = results.get("status", "").lower()
|
|
271
|
+
|
|
272
|
+
# Check if it's a recurring payment
|
|
273
|
+
if results.get("recurring_uid"):
|
|
274
|
+
if status == "success" or status == "approved":
|
|
275
|
+
return WebhookEventType.RECURRING_CHARGED
|
|
276
|
+
elif status in ("error", "failed", "declined"):
|
|
277
|
+
return WebhookEventType.RECURRING_FAILED
|
|
278
|
+
elif status == "canceled":
|
|
279
|
+
return WebhookEventType.RECURRING_CANCELED
|
|
280
|
+
|
|
281
|
+
# Check if it's a regular payment
|
|
282
|
+
if results.get("transaction_uid"):
|
|
283
|
+
if status == "success" or status == "approved":
|
|
284
|
+
return WebhookEventType.PAYMENT_SUCCESS
|
|
285
|
+
elif status in ("error", "failed", "declined"):
|
|
286
|
+
return WebhookEventType.PAYMENT_FAILURE
|
|
287
|
+
elif status == "pending":
|
|
288
|
+
return WebhookEventType.PAYMENT_PENDING
|
|
289
|
+
|
|
290
|
+
# Check for refund
|
|
291
|
+
if results.get("refund_uid") or "refund" in str(data).lower():
|
|
292
|
+
if status == "success" or status == "approved":
|
|
293
|
+
return WebhookEventType.REFUND_SUCCESS
|
|
294
|
+
else:
|
|
295
|
+
return WebhookEventType.REFUND_FAILURE
|
|
296
|
+
|
|
297
|
+
# Check for token creation
|
|
298
|
+
if results.get("card_uid") and not results.get("transaction_uid"):
|
|
299
|
+
return WebhookEventType.TOKEN_CREATED
|
|
300
|
+
|
|
301
|
+
return WebhookEventType.UNKNOWN
|
|
302
|
+
|
|
303
|
+
def _dispatch(self, event: WebhookEvent) -> None:
|
|
304
|
+
"""Dispatch event to registered handlers."""
|
|
305
|
+
handlers = self._handlers.get(event.type.value, [])
|
|
306
|
+
handlers.extend(self._handlers.get("*", [])) # Catch-all handlers
|
|
307
|
+
|
|
308
|
+
for handler in handlers:
|
|
309
|
+
try:
|
|
310
|
+
handler(event)
|
|
311
|
+
except Exception:
|
|
312
|
+
# Log error but continue with other handlers
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
async def _dispatch_async(self, event: WebhookEvent) -> None:
|
|
316
|
+
"""Dispatch event to registered async handlers."""
|
|
317
|
+
import asyncio
|
|
318
|
+
|
|
319
|
+
handlers = self._handlers.get(event.type.value, [])
|
|
320
|
+
handlers.extend(self._handlers.get("*", []))
|
|
321
|
+
|
|
322
|
+
for handler in handlers:
|
|
323
|
+
try:
|
|
324
|
+
result = handler(event)
|
|
325
|
+
if asyncio.iscoroutine(result):
|
|
326
|
+
await result
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def create_fastapi_webhook_router(
|
|
332
|
+
handler: WebhookHandler,
|
|
333
|
+
path: str = "/webhooks/payplus",
|
|
334
|
+
) -> Any:
|
|
335
|
+
"""
|
|
336
|
+
Create a FastAPI router for webhooks.
|
|
337
|
+
|
|
338
|
+
Usage:
|
|
339
|
+
from fastapi import FastAPI
|
|
340
|
+
from payplus import PayPlus
|
|
341
|
+
from payplus.webhooks import WebhookHandler, create_fastapi_webhook_router
|
|
342
|
+
|
|
343
|
+
app = FastAPI()
|
|
344
|
+
client = PayPlus(...)
|
|
345
|
+
handler = WebhookHandler(client)
|
|
346
|
+
|
|
347
|
+
router = create_fastapi_webhook_router(handler)
|
|
348
|
+
app.include_router(router)
|
|
349
|
+
"""
|
|
350
|
+
try:
|
|
351
|
+
from fastapi import APIRouter, Request, HTTPException
|
|
352
|
+
except ImportError:
|
|
353
|
+
raise ImportError("FastAPI is required. Install with: pip install payplus-sdk[fastapi]")
|
|
354
|
+
|
|
355
|
+
router = APIRouter()
|
|
356
|
+
|
|
357
|
+
@router.post(path)
|
|
358
|
+
async def webhook_endpoint(request: Request):
|
|
359
|
+
payload = await request.body()
|
|
360
|
+
signature = request.headers.get("X-PayPlus-Signature", "")
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
event = await handler.handle_async(payload, signature)
|
|
364
|
+
return {"received": True, "event_id": event.id, "event_type": event.type.value}
|
|
365
|
+
except WebhookSignatureError:
|
|
366
|
+
raise HTTPException(status_code=400, detail="Invalid signature")
|
|
367
|
+
except WebhookError as e:
|
|
368
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
369
|
+
|
|
370
|
+
return router
|