paymenthub 0.1.0__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.
paymenthub/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """PaymentHub Python SDK — typed client + webhook verification."""
2
+
3
+ from ._models import (
4
+ PaymentCreate,
5
+ PaymentDetailResponse,
6
+ PaymentResponse,
7
+ SandboxSeedResponse,
8
+ )
9
+ from .client import DEFAULT_BASE_URL, PaymentHubClient, PaymentHubError
10
+ from .webhook import (
11
+ WebhookProvider,
12
+ verify_paymob_hmac,
13
+ verify_stripe_signature,
14
+ verify_webhook,
15
+ )
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ __all__ = [
20
+ "DEFAULT_BASE_URL",
21
+ "PaymentCreate",
22
+ "PaymentDetailResponse",
23
+ "PaymentHubClient",
24
+ "PaymentHubError",
25
+ "PaymentResponse",
26
+ "SandboxSeedResponse",
27
+ "WebhookProvider",
28
+ "__version__",
29
+ "verify_paymob_hmac",
30
+ "verify_stripe_signature",
31
+ "verify_webhook",
32
+ ]
paymenthub/_models.py ADDED
@@ -0,0 +1,678 @@
1
+ # generated by datamodel-codegen:
2
+ # filename: openapi.json
3
+ # timestamp: 2026-06-06T11:49:38+00:00
4
+
5
+ from __future__ import annotations
6
+
7
+ from enum import Enum
8
+ from typing import Any, Literal
9
+
10
+ from pydantic import AwareDatetime, BaseModel, EmailStr, Field, conint, constr
11
+
12
+
13
+ class AccountDeleteResponse(BaseModel):
14
+ closed_at: AwareDatetime = Field(..., title='Closed At')
15
+ merchant_id: str = Field(..., title='Merchant Id')
16
+ status: Literal['closed'] = Field(..., title='Status')
17
+
18
+
19
+ class Env(Enum):
20
+ test = 'test'
21
+ live = 'live'
22
+
23
+
24
+ class Scope(Enum):
25
+ payments_read = 'payments:read'
26
+ payments_write = 'payments:write'
27
+ refunds_write = 'refunds:write'
28
+ webhooks_read = 'webhooks:read'
29
+ disputes_read = 'disputes:read'
30
+ disputes_write = 'disputes:write'
31
+ settings_read = 'settings:read'
32
+ all = 'all'
33
+
34
+
35
+ class ApiKeyCreateRequest(BaseModel):
36
+ allowed_ips: list[str] | None = Field(None, title='Allowed Ips')
37
+ env: Env | None = Field('test', title='Env')
38
+ label: constr(min_length=1, max_length=120) = Field(..., title='Label')
39
+ scopes: list[Scope] | None = Field(None, title='Scopes')
40
+
41
+
42
+ class ApiKeySummary(BaseModel):
43
+ allowed_ips: list[str] | None = Field(..., title='Allowed Ips')
44
+ created_at: AwareDatetime = Field(..., title='Created At')
45
+ env: Env = Field(..., title='Env')
46
+ id: str = Field(..., title='Id')
47
+ label: str = Field(..., title='Label')
48
+ last_used_at: AwareDatetime | None = Field(..., title='Last Used At')
49
+ prefix: str = Field(..., title='Prefix')
50
+ scopes: list[str] = Field(..., title='Scopes')
51
+
52
+
53
+ class ApiKeyUpdateRequest(BaseModel):
54
+ allowed_ips: list[str] | None = Field(None, title='Allowed Ips')
55
+ scopes: list[Scope] | None = Field(None, title='Scopes')
56
+
57
+
58
+ class ApiKeyWithSecret(BaseModel):
59
+ allowed_ips: list[str] | None = Field(..., title='Allowed Ips')
60
+ created_at: AwareDatetime = Field(..., title='Created At')
61
+ env: Env = Field(..., title='Env')
62
+ id: str = Field(..., title='Id')
63
+ label: str = Field(..., title='Label')
64
+ prefix: str = Field(..., title='Prefix')
65
+ scopes: list[str] = Field(..., title='Scopes')
66
+ secret: str = Field(..., title='Secret')
67
+
68
+
69
+ class ActorType(Enum):
70
+ api_key = 'api_key'
71
+ dashboard_session = 'dashboard_session'
72
+ system = 'system'
73
+
74
+
75
+ class AuditLogResponse(BaseModel):
76
+ action: str = Field(..., title='Action')
77
+ actor_id: str | None = Field(..., title='Actor Id')
78
+ actor_type: ActorType = Field(..., title='Actor Type')
79
+ after: dict[str, Any] | None = Field(..., title='After')
80
+ before: dict[str, Any] | None = Field(..., title='Before')
81
+ created_at: AwareDatetime = Field(..., title='Created At')
82
+ id: str = Field(..., title='Id')
83
+ ip: str | None = Field(..., title='Ip')
84
+ request_id: str | None = Field(..., title='Request Id')
85
+ target_id: str | None = Field(..., title='Target Id')
86
+ target_type: str | None = Field(..., title='Target Type')
87
+ user_agent: str | None = Field(..., title='User Agent')
88
+
89
+
90
+ class Status(Enum):
91
+ pending = 'pending'
92
+ running = 'running'
93
+ ready = 'ready'
94
+ failed = 'failed'
95
+
96
+
97
+ class DataExportResponse(BaseModel):
98
+ byte_size: int | None = Field(None, title='Byte Size')
99
+ completed_at: AwareDatetime | None = Field(None, title='Completed At')
100
+ created_at: AwareDatetime = Field(..., title='Created At')
101
+ error: str | None = Field(None, title='Error')
102
+ expires_at: AwareDatetime | None = Field(None, title='Expires At')
103
+ id: str = Field(..., title='Id')
104
+ status: Status = Field(..., title='Status')
105
+
106
+
107
+ class Provider(Enum):
108
+ stripe = 'stripe'
109
+ paymob = 'paymob'
110
+ fawry = 'fawry'
111
+
112
+
113
+ class Status1(Enum):
114
+ open = 'open'
115
+ resolved = 'resolved'
116
+ noted = 'noted'
117
+
118
+
119
+ class Type(Enum):
120
+ missing_in_db = 'missing_in_db'
121
+ missing_in_settlement = 'missing_in_settlement'
122
+ amount_mismatch = 'amount_mismatch'
123
+ under_dispute = 'under_dispute'
124
+
125
+
126
+ class DiscrepancyResponse(BaseModel):
127
+ created_at: AwareDatetime = Field(..., title='Created At')
128
+ currency: str | None = Field(..., title='Currency')
129
+ delta_amount_minor: int | None = Field(..., title='Delta Amount Minor')
130
+ id: str = Field(..., title='Id')
131
+ payment_id: str | None = Field(..., title='Payment Id')
132
+ provider: Provider = Field(..., title='Provider')
133
+ resolution_note: str | None = Field(..., title='Resolution Note')
134
+ resolved_at: AwareDatetime | None = Field(..., title='Resolved At')
135
+ run_id: str = Field(..., title='Run Id')
136
+ settlement_line_id: str | None = Field(..., title='Settlement Line Id')
137
+ status: Status1 = Field(..., title='Status')
138
+ type: Type = Field(..., title='Type')
139
+
140
+
141
+ class DisputeEvidenceRequest(BaseModel):
142
+ customer_communication: str | None = Field(None, title='Customer Communication')
143
+ extra: dict[str, Any] | None = Field(None, title='Extra')
144
+ receipt: str | None = Field(None, title='Receipt')
145
+ refund_policy: str | None = Field(None, title='Refund Policy')
146
+ refund_refusal_explanation: str | None = Field(
147
+ None, title='Refund Refusal Explanation'
148
+ )
149
+ service_documentation: str | None = Field(None, title='Service Documentation')
150
+ shipping_documentation: str | None = Field(None, title='Shipping Documentation')
151
+ uncategorized_text: str | None = Field(None, title='Uncategorized Text')
152
+
153
+
154
+ class Reason(Enum):
155
+ fraudulent = 'fraudulent'
156
+ duplicate = 'duplicate'
157
+ product_not_received = 'product_not_received'
158
+ product_unacceptable = 'product_unacceptable'
159
+ subscription_canceled = 'subscription_canceled'
160
+ credit_not_processed = 'credit_not_processed'
161
+ general = 'general'
162
+ other = 'other'
163
+
164
+
165
+ class Status2(Enum):
166
+ needs_response = 'needs_response'
167
+ under_review = 'under_review'
168
+ won = 'won'
169
+ lost = 'lost'
170
+ accepted = 'accepted'
171
+ charge_refunded = 'charge_refunded'
172
+
173
+
174
+ class DisputeResponse(BaseModel):
175
+ amount_minor: int = Field(..., title='Amount Minor')
176
+ currency: str = Field(..., title='Currency')
177
+ evidence_due_by: AwareDatetime | None = Field(..., title='Evidence Due By')
178
+ id: str = Field(..., title='Id')
179
+ opened_at: AwareDatetime = Field(..., title='Opened At')
180
+ payment_id: str = Field(..., title='Payment Id')
181
+ provider: str = Field(..., title='Provider')
182
+ provider_dispute_id: str = Field(..., title='Provider Dispute Id')
183
+ reason: Reason | None = Field(..., title='Reason')
184
+ resolved_at: AwareDatetime | None = Field(..., title='Resolved At')
185
+ status: Status2 = Field(..., title='Status')
186
+
187
+
188
+ class InviteAcceptRequest(BaseModel):
189
+ password: constr(min_length=8, max_length=128) = Field(..., title='Password')
190
+ token: constr(min_length=1, max_length=256) = Field(..., title='Token')
191
+
192
+
193
+ class Role(Enum):
194
+ owner = 'owner'
195
+ admin = 'admin'
196
+ operator = 'operator'
197
+ viewer = 'viewer'
198
+
199
+
200
+ class InviteCreateRequest(BaseModel):
201
+ email: EmailStr = Field(..., title='Email')
202
+ role: Role | None = Field('operator', title='Role')
203
+
204
+
205
+ class LoginRequest(BaseModel):
206
+ email: EmailStr = Field(..., title='Email')
207
+ password: str = Field(..., title='Password')
208
+ totp_code: constr(max_length=32) | None = Field(None, title='Totp Code')
209
+
210
+
211
+ class Status3(Enum):
212
+ pending = 'pending'
213
+ succeeded = 'succeeded'
214
+ failed = 'failed'
215
+
216
+
217
+ class PaymentAttemptResponse(BaseModel):
218
+ created_at: AwareDatetime = Field(..., title='Created At')
219
+ error_code: str | None = Field(..., title='Error Code')
220
+ error_message: str | None = Field(..., title='Error Message')
221
+ failover_from: str | None = Field(None, title='Failover From')
222
+ id: str = Field(..., title='Id')
223
+ payment_id: str = Field(..., title='Payment Id')
224
+ provider: Provider = Field(..., title='Provider')
225
+ provider_payment_id: str | None = Field(..., title='Provider Payment Id')
226
+ status: Status3 = Field(..., title='Status')
227
+
228
+
229
+ class PaymentCustomer(BaseModel):
230
+ email: EmailStr | None = Field(None, title='Email')
231
+ name: str | None = Field(None, title='Name')
232
+ phone: str | None = Field(None, title='Phone')
233
+
234
+
235
+ class Status4(Enum):
236
+ pending = 'pending'
237
+ pending_unknown = 'pending_unknown'
238
+ requires_action = 'requires_action'
239
+ succeeded = 'succeeded'
240
+ failed = 'failed'
241
+ refunded = 'refunded'
242
+ partially_refunded = 'partially_refunded'
243
+ cancelled = 'cancelled'
244
+ disputed = 'disputed'
245
+
246
+
247
+ class Status5(Enum):
248
+ pending = 'pending'
249
+ succeeded = 'succeeded'
250
+ failed = 'failed'
251
+
252
+
253
+ class PaymentRefundSummary(BaseModel):
254
+ amount_minor: int = Field(..., title='Amount Minor')
255
+ created_at: AwareDatetime = Field(..., title='Created At')
256
+ currency: str = Field(..., title='Currency')
257
+ id: str = Field(..., title='Id')
258
+ note: str | None = Field(..., title='Note')
259
+ payment_id: str = Field(..., title='Payment Id')
260
+ provider_refund_id: str | None = Field(..., title='Provider Refund Id')
261
+ reason: str | None = Field(..., title='Reason')
262
+ status: Status5 = Field(..., title='Status')
263
+
264
+
265
+ class Status6(Enum):
266
+ pending = 'pending'
267
+ pending_unknown = 'pending_unknown'
268
+ requires_action = 'requires_action'
269
+ succeeded = 'succeeded'
270
+ failed = 'failed'
271
+ refunded = 'refunded'
272
+ partially_refunded = 'partially_refunded'
273
+ cancelled = 'cancelled'
274
+ disputed = 'disputed'
275
+
276
+
277
+ class PaymentResponse(BaseModel):
278
+ amount_minor: int = Field(..., title='Amount Minor')
279
+ checkout_url: str | None = Field(..., title='Checkout Url')
280
+ created_at: AwareDatetime = Field(..., title='Created At')
281
+ currency: str = Field(..., title='Currency')
282
+ customer: PaymentCustomer | None = None
283
+ description: str | None = Field(None, title='Description')
284
+ id: str = Field(..., title='Id')
285
+ merchant_id: str = Field(..., title='Merchant Id')
286
+ metadata: dict[str, str] | None = Field(None, title='Metadata')
287
+ provider: Provider = Field(..., title='Provider')
288
+ provider_payment_id: str | None = Field(None, title='Provider Payment Id')
289
+ refundable_minor: int | None = Field(0, title='Refundable Minor')
290
+ refunded_minor: int | None = Field(0, title='Refunded Minor')
291
+ status: Status6 = Field(..., title='Status')
292
+ updated_at: AwareDatetime = Field(..., title='Updated At')
293
+
294
+
295
+ class PaymentWebhookDeliverySummary(BaseModel):
296
+ attempts: int = Field(..., title='Attempts')
297
+ created_at: AwareDatetime = Field(..., title='Created At')
298
+ event_id: str = Field(..., title='Event Id')
299
+ event_type: str = Field(..., title='Event Type')
300
+ id: str = Field(..., title='Id')
301
+ last_latency_ms: int | None = Field(..., title='Last Latency Ms')
302
+ last_status_code: int | None = Field(..., title='Last Status Code')
303
+ max_attempts: int = Field(..., title='Max Attempts')
304
+ next_retry_at: AwareDatetime | None = Field(..., title='Next Retry At')
305
+ payment_id: str | None = Field(..., title='Payment Id')
306
+ signature: str | None = Field('', title='Signature')
307
+ status: str = Field(..., title='Status')
308
+ updated_at: AwareDatetime = Field(..., title='Updated At')
309
+ url: str = Field(..., title='Url')
310
+
311
+
312
+ class ProfileResponse(BaseModel):
313
+ contact_email: str = Field(..., title='Contact Email')
314
+ contact_phone: str | None = Field(None, title='Contact Phone')
315
+ display_name: str = Field(..., title='Display Name')
316
+ merchant_id: str = Field(..., title='Merchant Id')
317
+ merchant_name: str = Field(..., title='Merchant Name')
318
+ timezone: str | None = Field('UTC', title='Timezone')
319
+ two_factor_enabled: bool | None = Field(False, title='Two Factor Enabled')
320
+
321
+
322
+ class ProfileUpdateRequest(BaseModel):
323
+ contact_email: EmailStr | None = Field(None, title='Contact Email')
324
+ contact_phone: constr(max_length=32) | None = Field(None, title='Contact Phone')
325
+ display_name: constr(min_length=1, max_length=255) | None = Field(
326
+ None, title='Display Name'
327
+ )
328
+ timezone: constr(max_length=64) | None = Field(None, title='Timezone')
329
+
330
+
331
+ class Status7(Enum):
332
+ running = 'running'
333
+ succeeded = 'succeeded'
334
+ failed = 'failed'
335
+
336
+
337
+ class ReconciliationRunResponse(BaseModel):
338
+ discrepancies_open: int = Field(..., title='Discrepancies Open')
339
+ discrepancies_resolved: int = Field(..., title='Discrepancies Resolved')
340
+ finished_at: AwareDatetime | None = Field(..., title='Finished At')
341
+ id: str = Field(..., title='Id')
342
+ provider: Provider = Field(..., title='Provider')
343
+ records_matched: int = Field(..., title='Records Matched')
344
+ started_at: AwareDatetime = Field(..., title='Started At')
345
+ status: Status7 = Field(..., title='Status')
346
+
347
+
348
+ class RefundCreate(BaseModel):
349
+ amount_minor: conint(ge=1) | None = Field(
350
+ None, description='Omit for full refund.', title='Amount Minor'
351
+ )
352
+ payment_id: str = Field(..., title='Payment Id')
353
+ reason: str | None = Field(None, title='Reason')
354
+
355
+
356
+ class Status8(Enum):
357
+ pending = 'pending'
358
+ succeeded = 'succeeded'
359
+ failed = 'failed'
360
+
361
+
362
+ class RefundResponse(BaseModel):
363
+ amount_minor: int = Field(..., title='Amount Minor')
364
+ created_at: AwareDatetime = Field(..., title='Created At')
365
+ currency: str = Field(..., title='Currency')
366
+ id: str = Field(..., title='Id')
367
+ note: str | None = Field(None, title='Note')
368
+ payment_id: str = Field(..., title='Payment Id')
369
+ provider_refund_id: str | None = Field(None, title='Provider Refund Id')
370
+ reason: str | None = Field(..., title='Reason')
371
+ status: Status8 = Field(..., title='Status')
372
+ updated_at: AwareDatetime = Field(..., title='Updated At')
373
+
374
+
375
+ class ResolveBody(BaseModel):
376
+ note: str | None = Field(None, title='Note')
377
+
378
+
379
+ class RetryTickResponse(BaseModel):
380
+ due: int = Field(..., title='Due')
381
+ forwarded: int = Field(..., title='Forwarded')
382
+
383
+
384
+ class RoleUpdateRequest(BaseModel):
385
+ role: Role = Field(..., title='Role')
386
+
387
+
388
+ class SandboxSeedResponse(BaseModel):
389
+ api_key: str = Field(..., title='Api Key')
390
+ merchant_id: str = Field(..., title='Merchant Id')
391
+ sample_payment_ids: list[str] = Field(..., title='Sample Payment Ids')
392
+
393
+
394
+ class SessionListEntry(BaseModel):
395
+ created_at: AwareDatetime = Field(..., title='Created At')
396
+ current: bool = Field(..., title='Current')
397
+ id: str = Field(..., title='Id')
398
+ ip: str | None = Field(..., title='Ip')
399
+ last_seen_at: AwareDatetime = Field(..., title='Last Seen At')
400
+ user_agent: str | None = Field(..., title='User Agent')
401
+
402
+
403
+ class SessionListResponse(BaseModel):
404
+ data: list[SessionListEntry] = Field(..., title='Data')
405
+
406
+
407
+ class SessionResponse(BaseModel):
408
+ access_token: str = Field(..., title='Access Token')
409
+ expires_in: int = Field(..., title='Expires In')
410
+ merchant_id: str = Field(..., title='Merchant Id')
411
+ merchant_name: str | None = Field(None, title='Merchant Name')
412
+ role: str | None = Field(None, title='Role')
413
+
414
+
415
+ class SignupRequest(BaseModel):
416
+ email: EmailStr = Field(..., title='Email')
417
+ name: constr(min_length=1, max_length=255) = Field(
418
+ ..., description='Merchant / business name', title='Name'
419
+ )
420
+ password: constr(min_length=8, max_length=128) = Field(..., title='Password')
421
+
422
+
423
+ class TeamInviteOut(BaseModel):
424
+ created_at: AwareDatetime = Field(..., title='Created At')
425
+ email: str = Field(..., title='Email')
426
+ expires_at: AwareDatetime = Field(..., title='Expires At')
427
+ id: str = Field(..., title='Id')
428
+ role: Role = Field(..., title='Role')
429
+
430
+
431
+ class TeamMember(BaseModel):
432
+ created_at: AwareDatetime = Field(..., title='Created At')
433
+ disabled_at: AwareDatetime | None = Field(..., title='Disabled At')
434
+ email: str = Field(..., title='Email')
435
+ id: str = Field(..., title='Id')
436
+ last_seen_at: AwareDatetime | None = Field(..., title='Last Seen At')
437
+ role: Role = Field(..., title='Role')
438
+
439
+
440
+ class Kind(Enum):
441
+ payment_created = 'payment.created'
442
+ payment_requires_action = 'payment.requires_action'
443
+ payment_succeeded = 'payment.succeeded'
444
+ payment_failed = 'payment.failed'
445
+ payment_refunded = 'payment.refunded'
446
+ webhook_delivered = 'webhook.delivered'
447
+ webhook_failed = 'webhook.failed'
448
+
449
+
450
+ class Tone(Enum):
451
+ success = 'success'
452
+ warn = 'warn'
453
+ danger = 'danger'
454
+ info = 'info'
455
+ neutral = 'neutral'
456
+
457
+
458
+ class TimelineEventResponse(BaseModel):
459
+ at: AwareDatetime = Field(..., title='At')
460
+ id: str = Field(..., title='Id')
461
+ kind: Kind = Field(..., title='Kind')
462
+ meta: str | None = Field(None, title='Meta')
463
+ title: str = Field(..., title='Title')
464
+ tone: Tone = Field(..., title='Tone')
465
+
466
+
467
+ class TotpCodeRequest(BaseModel):
468
+ code: constr(min_length=1, max_length=32) = Field(
469
+ ..., description='TOTP or backup code', title='Code'
470
+ )
471
+
472
+
473
+ class TotpEnrollResponse(BaseModel):
474
+ backup_codes: list[str] = Field(..., title='Backup Codes')
475
+ otpauth_uri: str = Field(..., title='Otpauth Uri')
476
+ qr_svg: str = Field(..., title='Qr Svg')
477
+ secret: str = Field(..., title='Secret')
478
+
479
+
480
+ class ValidationError(BaseModel):
481
+ ctx: dict[str, Any] | None = Field(None, title='Context')
482
+ input: Any | None = Field(None, title='Input')
483
+ loc: list[str | int] = Field(..., title='Location')
484
+ msg: str = Field(..., title='Message')
485
+ type: str = Field(..., title='Error Type')
486
+
487
+
488
+ class Status9(Enum):
489
+ pending = 'pending'
490
+ retrying = 'retrying'
491
+ delivered = 'delivered'
492
+ dead_letter = 'dead_letter'
493
+
494
+
495
+ class WebhookDeliveryResponse(BaseModel):
496
+ attempts: int = Field(..., title='Attempts')
497
+ created_at: AwareDatetime = Field(..., title='Created At')
498
+ delivered_at: AwareDatetime | None = Field(..., title='Delivered At')
499
+ event_id: str = Field(..., title='Event Id')
500
+ event_type: str = Field(..., title='Event Type')
501
+ id: str = Field(..., title='Id')
502
+ last_error: str | None = Field(..., title='Last Error')
503
+ last_latency_ms: int | None = Field(..., title='Last Latency Ms')
504
+ last_status_code: int | None = Field(..., title='Last Status Code')
505
+ max_attempts: int = Field(..., title='Max Attempts')
506
+ next_retry_at: AwareDatetime | None = Field(..., title='Next Retry At')
507
+ payment_id: str | None = Field(..., title='Payment Id')
508
+ signature: str | None = Field(..., title='Signature')
509
+ status: Status9 = Field(..., title='Status')
510
+ updated_at: AwareDatetime = Field(..., title='Updated At')
511
+ url: str = Field(..., title='Url')
512
+
513
+
514
+ class Status11(Enum):
515
+ delivered = 'delivered'
516
+ failed = 'failed'
517
+ scheduled = 'scheduled'
518
+
519
+
520
+ class WebhookDeliveryRetryEntry(BaseModel):
521
+ at: AwareDatetime = Field(..., title='At')
522
+ attempt: int = Field(..., title='Attempt')
523
+ error: str | None = Field(None, title='Error')
524
+ latency_ms: int | None = Field(None, title='Latency Ms')
525
+ status: Status11 = Field(..., title='Status')
526
+ status_code: int | None = Field(None, title='Status Code')
527
+
528
+
529
+ class WebhookSecretRotateResponse(BaseModel):
530
+ previous_secret_revoked_at: AwareDatetime = Field(
531
+ ..., title='Previous Secret Revoked At'
532
+ )
533
+ secret: str = Field(..., title='Secret')
534
+
535
+
536
+ class LastTestStatus(Enum):
537
+ succeeded = 'succeeded'
538
+ failed = 'failed'
539
+
540
+
541
+ class SubscribedEvent(Enum):
542
+ payment_succeeded = 'payment.succeeded'
543
+ payment_failed = 'payment.failed'
544
+ payment_refunded = 'payment.refunded'
545
+ refund_succeeded = 'refund.succeeded'
546
+ refund_failed = 'refund.failed'
547
+ dispute_created = 'dispute.created'
548
+ dispute_updated = 'dispute.updated'
549
+ dispute_closed = 'dispute.closed'
550
+
551
+
552
+ class WebhookSettingsResponse(BaseModel):
553
+ endpoint_url: str | None = Field(..., title='Endpoint Url')
554
+ last_test_at: AwareDatetime | None = Field(None, title='Last Test At')
555
+ last_test_status: LastTestStatus | None = Field(None, title='Last Test Status')
556
+ secret_preview: str | None = Field(..., title='Secret Preview')
557
+ subscribed_events: list[SubscribedEvent] = Field(..., title='Subscribed Events')
558
+
559
+
560
+ class WebhookSettingsUpdateRequest(BaseModel):
561
+ endpoint_url: constr(max_length=2048) | None = Field(None, title='Endpoint Url')
562
+ subscribed_events: list[SubscribedEvent] | None = Field(
563
+ None, title='Subscribed Events'
564
+ )
565
+
566
+
567
+ class WebhookTestResponse(BaseModel):
568
+ error: str | None = Field(None, title='Error')
569
+ latency_ms: int | None = Field(None, title='Latency Ms')
570
+ ok: bool = Field(..., title='Ok')
571
+ status_code: int | None = Field(None, title='Status Code')
572
+
573
+
574
+ class ApiKeyListResponse(BaseModel):
575
+ data: list[ApiKeySummary] = Field(..., title='Data')
576
+
577
+
578
+ class AuditLogListResponse(BaseModel):
579
+ data: list[AuditLogResponse] = Field(..., title='Data')
580
+ next_cursor: str | None = Field(..., title='Next Cursor')
581
+
582
+
583
+ class DiscrepancyListResponse(BaseModel):
584
+ data: list[DiscrepancyResponse] = Field(..., title='Data')
585
+ next_cursor: str | None = Field(..., title='Next Cursor')
586
+
587
+
588
+ class DisputeListResponse(BaseModel):
589
+ data: list[DisputeResponse] = Field(..., title='Data')
590
+ next_cursor: str | None = Field(..., title='Next Cursor')
591
+
592
+
593
+ class HTTPValidationError(BaseModel):
594
+ detail: list[ValidationError] | None = Field(None, title='Detail')
595
+
596
+
597
+ class PaymentCreate(BaseModel):
598
+ amount_minor: conint(ge=1) = Field(
599
+ ..., description='Amount in minor units (cents/piasters).', title='Amount Minor'
600
+ )
601
+ bin_hint: constr(pattern=r'^\d+$', min_length=6, max_length=8) | None = Field(
602
+ None,
603
+ description='First 6-8 digits of the card BIN, used for routing.',
604
+ title='Bin Hint',
605
+ )
606
+ cancel_url: str = Field(..., title='Cancel Url')
607
+ currency: constr(min_length=3, max_length=3) = Field(..., title='Currency')
608
+ customer: PaymentCustomer | None = None
609
+ description: str | None = Field(None, title='Description')
610
+ metadata: dict[str, str] | None = Field(None, title='Metadata')
611
+ success_url: str = Field(..., title='Success Url')
612
+
613
+
614
+ class PaymentDetailResponse(BaseModel):
615
+ amount_minor: int = Field(..., title='Amount Minor')
616
+ attempts: list[PaymentAttemptResponse] | None = Field(None, title='Attempts')
617
+ checkout_url: str | None = Field(..., title='Checkout Url')
618
+ created_at: AwareDatetime = Field(..., title='Created At')
619
+ currency: str = Field(..., title='Currency')
620
+ customer: PaymentCustomer | None = None
621
+ description: str | None = Field(None, title='Description')
622
+ id: str = Field(..., title='Id')
623
+ merchant_id: str = Field(..., title='Merchant Id')
624
+ metadata: dict[str, str] | None = Field(None, title='Metadata')
625
+ provider: Provider = Field(..., title='Provider')
626
+ provider_payment_id: str | None = Field(None, title='Provider Payment Id')
627
+ raw_provider_response: dict[str, Any] | None = Field(
628
+ None, title='Raw Provider Response'
629
+ )
630
+ refundable_minor: int | None = Field(0, title='Refundable Minor')
631
+ refunded_minor: int | None = Field(0, title='Refunded Minor')
632
+ refunds: list[PaymentRefundSummary] | None = Field(None, title='Refunds')
633
+ status: Status4 = Field(..., title='Status')
634
+ timeline: list[TimelineEventResponse] | None = Field(None, title='Timeline')
635
+ updated_at: AwareDatetime = Field(..., title='Updated At')
636
+ webhook_deliveries: list[PaymentWebhookDeliverySummary] | None = Field(
637
+ None, title='Webhook Deliveries'
638
+ )
639
+
640
+
641
+ class PaymentListResponse(BaseModel):
642
+ data: list[PaymentResponse] = Field(..., title='Data')
643
+ next_cursor: str | None = Field(..., title='Next Cursor')
644
+
645
+
646
+ class ReconciliationRunListResponse(BaseModel):
647
+ data: list[ReconciliationRunResponse] = Field(..., title='Data')
648
+
649
+
650
+ class TeamListResponse(BaseModel):
651
+ invites: list[TeamInviteOut] = Field(..., title='Invites')
652
+ members: list[TeamMember] = Field(..., title='Members')
653
+
654
+
655
+ class WebhookDeliveryDetailResponse(BaseModel):
656
+ attempts: int = Field(..., title='Attempts')
657
+ created_at: AwareDatetime = Field(..., title='Created At')
658
+ delivered_at: AwareDatetime | None = Field(..., title='Delivered At')
659
+ event_id: str = Field(..., title='Event Id')
660
+ event_type: str = Field(..., title='Event Type')
661
+ id: str = Field(..., title='Id')
662
+ last_error: str | None = Field(..., title='Last Error')
663
+ last_latency_ms: int | None = Field(..., title='Last Latency Ms')
664
+ last_status_code: int | None = Field(..., title='Last Status Code')
665
+ max_attempts: int = Field(..., title='Max Attempts')
666
+ next_retry_at: AwareDatetime | None = Field(..., title='Next Retry At')
667
+ payload: dict[str, Any] | None = Field(..., title='Payload')
668
+ payment_id: str | None = Field(..., title='Payment Id')
669
+ retry_history: list[WebhookDeliveryRetryEntry] = Field(..., title='Retry History')
670
+ signature: str | None = Field(..., title='Signature')
671
+ status: Status9 = Field(..., title='Status')
672
+ updated_at: AwareDatetime = Field(..., title='Updated At')
673
+ url: str = Field(..., title='Url')
674
+
675
+
676
+ class WebhookDeliveryListResponse(BaseModel):
677
+ data: list[WebhookDeliveryResponse] = Field(..., title='Data')
678
+ next_cursor: str | None = Field(..., title='Next Cursor')
paymenthub/client.py ADDED
@@ -0,0 +1,89 @@
1
+ """Typed PaymentHub client (httpx)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from ._models import (
10
+ PaymentCreate,
11
+ PaymentDetailResponse,
12
+ PaymentResponse,
13
+ SandboxSeedResponse,
14
+ )
15
+
16
+ #: The hosted production gateway. Override via ``base_url`` for local/staging.
17
+ DEFAULT_BASE_URL = "https://paymenthub-be.onrender.com"
18
+
19
+
20
+ class PaymentHubError(Exception):
21
+ """Raised when the API returns a non-2xx response."""
22
+
23
+ def __init__(self, message: str, status_code: int, detail: Any = None) -> None:
24
+ super().__init__(message)
25
+ self.status_code = status_code
26
+ self.detail = detail
27
+
28
+
29
+ class PaymentHubClient:
30
+ """A typed PaymentHub client.
31
+
32
+ Use as a context manager (``with PaymentHubClient(key) as ph: ...``) or call
33
+ :meth:`close` when done. Pass ``http_client`` to inject a custom/mocked
34
+ ``httpx.Client`` (e.g. in tests).
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ api_key: str,
40
+ *,
41
+ base_url: str = DEFAULT_BASE_URL,
42
+ timeout: float = 30.0,
43
+ http_client: httpx.Client | None = None,
44
+ ) -> None:
45
+ self._client = http_client or httpx.Client(base_url=base_url, timeout=timeout)
46
+ self._owns_client = http_client is None
47
+ self._headers = {"Authorization": f"Bearer {api_key}"}
48
+
49
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
50
+ resp = self._client.request(method, path, headers=self._headers, **kwargs)
51
+ if resp.status_code >= 400:
52
+ detail: Any
53
+ try:
54
+ detail = resp.json()
55
+ except Exception:
56
+ detail = resp.text
57
+ raise PaymentHubError(
58
+ f"{method} {path} -> {resp.status_code}", resp.status_code, detail
59
+ )
60
+ return resp.json()
61
+
62
+ def seed_sandbox(self) -> SandboxSeedResponse:
63
+ """Bootstrap a sandbox dataset (test-mode keys only)."""
64
+ return SandboxSeedResponse.model_validate(self._request("POST", "/v1/sandbox/seed"))
65
+
66
+ def create_payment(self, payment: PaymentCreate | dict[str, Any]) -> PaymentResponse:
67
+ """Create a payment."""
68
+ body = (
69
+ payment.model_dump(exclude_none=True, mode="json")
70
+ if isinstance(payment, PaymentCreate)
71
+ else payment
72
+ )
73
+ return PaymentResponse.model_validate(self._request("POST", "/v1/payments", json=body))
74
+
75
+ def get_payment(self, payment_id: str) -> PaymentDetailResponse:
76
+ """Fetch a single payment (with attempts / refunds / timeline)."""
77
+ return PaymentDetailResponse.model_validate(
78
+ self._request("GET", f"/v1/payments/{payment_id}")
79
+ )
80
+
81
+ def close(self) -> None:
82
+ if self._owns_client:
83
+ self._client.close()
84
+
85
+ def __enter__(self) -> PaymentHubClient:
86
+ return self
87
+
88
+ def __exit__(self, *exc: object) -> None:
89
+ self.close()
paymenthub/py.typed ADDED
File without changes
paymenthub/webhook.py ADDED
@@ -0,0 +1,83 @@
1
+ """Webhook signature verification.
2
+
3
+ Webhooks are verified server-side (you hold the signing secret). Two schemes:
4
+ - Stripe-style: a ``t=<unix>,v1=<hex>`` header; HMAC-SHA256 over ``f"{t}.{body}"``.
5
+ - Paymob-style: a raw HMAC-SHA256 hex digest over the raw request body.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import hmac
12
+ import time
13
+ from collections.abc import Callable
14
+ from typing import Literal
15
+
16
+ WebhookProvider = Literal["stripe", "paymob"]
17
+
18
+
19
+ def _to_body(payload: str | bytes) -> str:
20
+ return payload.decode("utf-8") if isinstance(payload, bytes) else payload
21
+
22
+
23
+ def verify_stripe_signature(
24
+ payload: str | bytes,
25
+ header: str,
26
+ secret: str,
27
+ *,
28
+ tolerance_seconds: int = 300,
29
+ now: Callable[[], int] | None = None,
30
+ ) -> bool:
31
+ """Verify a Stripe-style ``t=<unix>,v1=<hex>`` signature header.
32
+
33
+ Recomputes HMAC-SHA256 over ``f"{t}.{body}"`` and (optionally) enforces a
34
+ timestamp tolerance to blunt replay. Set ``tolerance_seconds=0`` to skip the
35
+ timestamp check.
36
+ """
37
+ fields: dict[str, str] = {}
38
+ for part in header.split(","):
39
+ key, sep, value = part.partition("=")
40
+ if sep:
41
+ fields[key.strip()] = value.strip()
42
+ t = fields.get("t")
43
+ v1 = fields.get("v1")
44
+ if not t or not v1:
45
+ return False
46
+
47
+ expected = hmac.new(
48
+ secret.encode(), f"{t}.{_to_body(payload)}".encode(), hashlib.sha256
49
+ ).hexdigest()
50
+ if not hmac.compare_digest(expected, v1):
51
+ return False
52
+
53
+ if tolerance_seconds > 0:
54
+ current = now() if now is not None else int(time.time())
55
+ try:
56
+ if abs(current - int(t)) > tolerance_seconds:
57
+ return False
58
+ except ValueError:
59
+ return False
60
+ return True
61
+
62
+
63
+ def verify_paymob_hmac(payload: str | bytes, signature_hex: str, secret: str) -> bool:
64
+ """Verify a raw HMAC-SHA256 hex signature (Paymob-style) over the raw body."""
65
+ expected = hmac.new(secret.encode(), _to_body(payload).encode(), hashlib.sha256).hexdigest()
66
+ return hmac.compare_digest(expected, signature_hex.strip().lower())
67
+
68
+
69
+ def verify_webhook(
70
+ *,
71
+ provider: WebhookProvider,
72
+ payload: str | bytes,
73
+ signature: str,
74
+ secret: str,
75
+ tolerance_seconds: int = 300,
76
+ ) -> bool:
77
+ """Provider-agnostic webhook verification. ``True`` iff the signature is
78
+ valid for the given raw body + secret."""
79
+ if provider == "stripe":
80
+ return verify_stripe_signature(
81
+ payload, signature, secret, tolerance_seconds=tolerance_seconds
82
+ )
83
+ return verify_paymob_hmac(payload, signature, secret)
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: paymenthub
3
+ Version: 0.1.0
4
+ Summary: Typed Python client + webhook verification for the PaymentHub API.
5
+ Project-URL: Repository, https://github.com/ahmeddmohamed-noon/paymenthub-sdk-python
6
+ Author: PaymentHub
7
+ License-Expression: MIT
8
+ Keywords: paymenthub,payments,paymob,sdk,stripe
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: httpx>=0.27
11
+ Requires-Dist: pydantic[email]>=2.7
12
+ Provides-Extra: dev
13
+ Requires-Dist: datamodel-code-generator>=0.26; extra == 'dev'
14
+ Requires-Dist: mypy>=1.11; extra == 'dev'
15
+ Requires-Dist: pytest>=8; extra == 'dev'
16
+ Requires-Dist: ruff>=0.7; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # paymenthub (Python SDK)
20
+
21
+ Typed Python client + webhook verification for the [PaymentHub](https://github.com/ahmeddmohamed-noon/paymenthub-be) API. Models are generated from PaymentHub's published OpenAPI spec with `datamodel-code-generator`, so responses are validated pydantic models.
22
+
23
+ > Sandbox-only portfolio project.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install paymenthub
29
+ ```
30
+
31
+ ## Quickstart
32
+
33
+ ```python
34
+ from paymenthub import PaymentHubClient, PaymentCreate
35
+
36
+ with PaymentHubClient(api_key="sk_test_…") as ph:
37
+ # One-call sandbox bootstrap (test-mode keys only):
38
+ demo = ph.seed_sandbox() # -> SandboxSeedResponse(api_key, merchant_id, sample_payment_ids)
39
+
40
+ payment = ph.create_payment(PaymentCreate(
41
+ amount_minor=1500,
42
+ currency="USD",
43
+ success_url="https://example.com/success",
44
+ cancel_url="https://example.com/cancel",
45
+ ))
46
+ print(payment.checkout_url)
47
+
48
+ detail = ph.get_payment(payment.id)
49
+ ```
50
+
51
+ `PaymentHubClient` defaults to the hosted gateway; pass `base_url=` to point elsewhere, or `http_client=` to inject a custom/mocked `httpx.Client`.
52
+
53
+ ## Webhook verification
54
+
55
+ Verify the **raw** request body server-side (don't re-serialize):
56
+
57
+ ```python
58
+ from paymenthub import verify_webhook
59
+
60
+ ok = verify_webhook(
61
+ provider="stripe", # or "paymob"
62
+ payload=raw_body, # str | bytes
63
+ signature=request.headers["x-paymenthub-signature"],
64
+ secret=WEBHOOK_SECRET,
65
+ )
66
+ ```
67
+
68
+ - **Stripe-style** (`verify_stripe_signature`): `t=<unix>,v1=<hex>` header; HMAC-SHA256 over `f"{t}.{body}"` with a replay tolerance (default 5 min).
69
+ - **Paymob-style** (`verify_paymob_hmac`): raw HMAC-SHA256 hex digest over the body.
70
+
71
+ All comparisons use `hmac.compare_digest` (constant-time).
72
+
73
+ ## Development
74
+
75
+ ```bash
76
+ uv run --extra dev ruff check .
77
+ uv run --extra dev mypy src
78
+ uv run --extra dev pytest
79
+ uv build
80
+ ```
81
+
82
+ Models live in `src/paymenthub/_models.py`, generated from the vendored `openapi.json`:
83
+
84
+ ```bash
85
+ uv run --with datamodel-code-generator datamodel-codegen \
86
+ --input openapi.json --input-file-type openapi \
87
+ --output src/paymenthub/_models.py \
88
+ --output-model-type pydantic_v2.BaseModel \
89
+ --target-python-version 3.10 --use-standard-collections --use-union-operator
90
+ ```
91
+
92
+ Refresh `openapi.json` from the backend and re-run when the API changes.
93
+
94
+ ## Releasing
95
+
96
+ `release.yml` publishes to PyPI on a `v*` tag (or a `repository_dispatch` from the backend) via **Trusted Publishing (OIDC)** — no token stored. It runs the test suite and **completes a sandbox payment against prod** before publishing. Configure a PyPI trusted publisher for this repo + workflow, and add a `PAYMENTHUB_TEST_KEY` repo secret for the smoke test.
@@ -0,0 +1,8 @@
1
+ paymenthub/__init__.py,sha256=_UUWg0rW1_0zu2tazWTdS1lEcHWxfMWgxZMSOHHBmnU,708
2
+ paymenthub/_models.py,sha256=gtK0D2cIhtyfr1OS_c38_icmlOUgGEUXn9-Zv8eDYQw,24806
3
+ paymenthub/client.py,sha256=XTr9PJb0RxgajblHsQXyS9Tz4LpbH1Ng0gpubn-F6xg,2897
4
+ paymenthub/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ paymenthub/webhook.py,sha256=NBIgMdt1BNjCSVFQoBUjLAisa2qa3B-kC5hZNlg32uo,2600
6
+ paymenthub-0.1.0.dist-info/METADATA,sha256=YAXkEYr8r4LwWB57f60wdfygiighTyZYWi8uX4z9xwE,3319
7
+ paymenthub-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
8
+ paymenthub-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any