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 +32 -0
- paymenthub/_models.py +678 -0
- paymenthub/client.py +89 -0
- paymenthub/py.typed +0 -0
- paymenthub/webhook.py +83 -0
- paymenthub-0.1.0.dist-info/METADATA +96 -0
- paymenthub-0.1.0.dist-info/RECORD +8 -0
- paymenthub-0.1.0.dist-info/WHEEL +4 -0
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,,
|