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