wishop 1.2.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.
- wishop/__init__.py +123 -0
- wishop/_parsing.py +120 -0
- wishop/_transport.py +73 -0
- wishop/client.py +34 -0
- wishop/errors.py +79 -0
- wishop/py.typed +0 -0
- wishop/resources/__init__.py +0 -0
- wishop/resources/charges.py +81 -0
- wishop/resources/payouts.py +92 -0
- wishop/resources/refunds.py +46 -0
- wishop/types.py +154 -0
- wishop/webhooks.py +69 -0
- wishop-1.2.0.dist-info/METADATA +340 -0
- wishop-1.2.0.dist-info/RECORD +16 -0
- wishop-1.2.0.dist-info/WHEEL +4 -0
- wishop-1.2.0.dist-info/licenses/LICENSE +21 -0
wishop/__init__.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from .client import VERSION, Wishop
|
|
2
|
+
from .errors import (
|
|
3
|
+
APIConnectionError,
|
|
4
|
+
APIError,
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
ChargeExpiredError,
|
|
7
|
+
ConflictError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
PayloadTooLargeError,
|
|
10
|
+
PermissionError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
SignatureVerificationError,
|
|
13
|
+
UnprocessableError,
|
|
14
|
+
ValidationError,
|
|
15
|
+
WishopError,
|
|
16
|
+
)
|
|
17
|
+
from .types import (
|
|
18
|
+
CHARGE_STATUS_EXPIRED,
|
|
19
|
+
CHARGE_STATUS_FAILED,
|
|
20
|
+
CHARGE_STATUS_INITIATED,
|
|
21
|
+
CHARGE_STATUS_OTP_SENT,
|
|
22
|
+
CHARGE_STATUS_PAID,
|
|
23
|
+
CHARGE_STATUS_REFUNDED,
|
|
24
|
+
EVENT_CHARGE_EXPIRED,
|
|
25
|
+
EVENT_CHARGE_FAILED,
|
|
26
|
+
EVENT_CHARGE_PAID,
|
|
27
|
+
EVENT_CHARGE_REFUNDED,
|
|
28
|
+
EVENT_PAYOUT_CREATED,
|
|
29
|
+
EVENT_PAYOUT_FAILED,
|
|
30
|
+
EVENT_PAYOUT_SUCCEEDED,
|
|
31
|
+
FAIL_REASON_DECLINED,
|
|
32
|
+
FAIL_REASON_INSUFFICIENT_FUNDS,
|
|
33
|
+
FAIL_REASON_INSUFFICIENT_MERCHANT_BALANCE,
|
|
34
|
+
FAIL_REASON_INTERNAL_ERROR,
|
|
35
|
+
FAIL_REASON_OTP_ATTEMPTS_EXCEEDED,
|
|
36
|
+
FAIL_REASON_OTP_EXPIRED,
|
|
37
|
+
FAIL_REASON_PIN_LOCKED,
|
|
38
|
+
FAIL_REASON_RECIPIENT_INELIGIBLE,
|
|
39
|
+
FAIL_REASON_TIMEOUT,
|
|
40
|
+
FAIL_REASON_WALLET_NOT_FOUND,
|
|
41
|
+
FEE_BEARER_CUSTOMER,
|
|
42
|
+
FEE_BEARER_MERCHANT,
|
|
43
|
+
PAYOUT_STATUS_FAILED,
|
|
44
|
+
PAYOUT_STATUS_PENDING,
|
|
45
|
+
PAYOUT_STATUS_SUCCEEDED,
|
|
46
|
+
REFUND_REASON_DUPLICATE,
|
|
47
|
+
REFUND_REASON_FRAUDULENT,
|
|
48
|
+
REFUND_REASON_OTHER,
|
|
49
|
+
REFUND_REASON_REQUESTED_BY_CUSTOMER,
|
|
50
|
+
REFUND_STATUS_FAILED,
|
|
51
|
+
REFUND_STATUS_PENDING,
|
|
52
|
+
REFUND_STATUS_SUCCEEDED,
|
|
53
|
+
Charge,
|
|
54
|
+
ChargeList,
|
|
55
|
+
Event,
|
|
56
|
+
EventData,
|
|
57
|
+
Payout,
|
|
58
|
+
PayoutList,
|
|
59
|
+
Refund,
|
|
60
|
+
)
|
|
61
|
+
from .webhooks import DEFAULT_TOLERANCE, construct_event
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"APIConnectionError",
|
|
65
|
+
"APIError",
|
|
66
|
+
"AuthenticationError",
|
|
67
|
+
"CHARGE_STATUS_EXPIRED",
|
|
68
|
+
"CHARGE_STATUS_FAILED",
|
|
69
|
+
"CHARGE_STATUS_INITIATED",
|
|
70
|
+
"CHARGE_STATUS_OTP_SENT",
|
|
71
|
+
"CHARGE_STATUS_PAID",
|
|
72
|
+
"CHARGE_STATUS_REFUNDED",
|
|
73
|
+
"Charge",
|
|
74
|
+
"ChargeExpiredError",
|
|
75
|
+
"ChargeList",
|
|
76
|
+
"ConflictError",
|
|
77
|
+
"DEFAULT_TOLERANCE",
|
|
78
|
+
"EVENT_CHARGE_EXPIRED",
|
|
79
|
+
"EVENT_CHARGE_FAILED",
|
|
80
|
+
"EVENT_CHARGE_PAID",
|
|
81
|
+
"EVENT_CHARGE_REFUNDED",
|
|
82
|
+
"EVENT_PAYOUT_CREATED",
|
|
83
|
+
"EVENT_PAYOUT_FAILED",
|
|
84
|
+
"EVENT_PAYOUT_SUCCEEDED",
|
|
85
|
+
"Event",
|
|
86
|
+
"EventData",
|
|
87
|
+
"FAIL_REASON_DECLINED",
|
|
88
|
+
"FAIL_REASON_INSUFFICIENT_FUNDS",
|
|
89
|
+
"FAIL_REASON_INSUFFICIENT_MERCHANT_BALANCE",
|
|
90
|
+
"FAIL_REASON_INTERNAL_ERROR",
|
|
91
|
+
"FAIL_REASON_OTP_ATTEMPTS_EXCEEDED",
|
|
92
|
+
"FAIL_REASON_OTP_EXPIRED",
|
|
93
|
+
"FAIL_REASON_PIN_LOCKED",
|
|
94
|
+
"FAIL_REASON_RECIPIENT_INELIGIBLE",
|
|
95
|
+
"FAIL_REASON_TIMEOUT",
|
|
96
|
+
"FAIL_REASON_WALLET_NOT_FOUND",
|
|
97
|
+
"FEE_BEARER_CUSTOMER",
|
|
98
|
+
"FEE_BEARER_MERCHANT",
|
|
99
|
+
"NotFoundError",
|
|
100
|
+
"PAYOUT_STATUS_FAILED",
|
|
101
|
+
"PAYOUT_STATUS_PENDING",
|
|
102
|
+
"PAYOUT_STATUS_SUCCEEDED",
|
|
103
|
+
"PayloadTooLargeError",
|
|
104
|
+
"Payout",
|
|
105
|
+
"PayoutList",
|
|
106
|
+
"PermissionError",
|
|
107
|
+
"REFUND_REASON_DUPLICATE",
|
|
108
|
+
"REFUND_REASON_FRAUDULENT",
|
|
109
|
+
"REFUND_REASON_OTHER",
|
|
110
|
+
"REFUND_REASON_REQUESTED_BY_CUSTOMER",
|
|
111
|
+
"REFUND_STATUS_FAILED",
|
|
112
|
+
"REFUND_STATUS_PENDING",
|
|
113
|
+
"REFUND_STATUS_SUCCEEDED",
|
|
114
|
+
"RateLimitError",
|
|
115
|
+
"Refund",
|
|
116
|
+
"SignatureVerificationError",
|
|
117
|
+
"UnprocessableError",
|
|
118
|
+
"VERSION",
|
|
119
|
+
"ValidationError",
|
|
120
|
+
"Wishop",
|
|
121
|
+
"WishopError",
|
|
122
|
+
"construct_event",
|
|
123
|
+
]
|
wishop/_parsing.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from .types import Charge, ChargeList, Event, EventData, Payout, PayoutList, Refund
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_datetime(value: Optional[str]) -> Optional[datetime]:
|
|
10
|
+
if not value:
|
|
11
|
+
return None
|
|
12
|
+
|
|
13
|
+
if value.endswith("Z"):
|
|
14
|
+
value = value[:-1] + "+00:00"
|
|
15
|
+
|
|
16
|
+
return datetime.fromisoformat(value)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_required_datetime(value: str) -> datetime:
|
|
20
|
+
parsed = parse_datetime(value)
|
|
21
|
+
|
|
22
|
+
if parsed is None:
|
|
23
|
+
raise ValueError("expected a timestamp")
|
|
24
|
+
|
|
25
|
+
return parsed
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_charge(payload: Dict[str, Any]) -> Charge:
|
|
29
|
+
return Charge(
|
|
30
|
+
id=payload["id"],
|
|
31
|
+
status=payload["status"],
|
|
32
|
+
currency=payload["currency"],
|
|
33
|
+
amount=payload["amount"],
|
|
34
|
+
livemode=payload["livemode"],
|
|
35
|
+
created_at=parse_required_datetime(payload["created_at"]),
|
|
36
|
+
order_id=payload.get("order_id"),
|
|
37
|
+
description=payload.get("description"),
|
|
38
|
+
fee_bearer=payload.get("fee_bearer"),
|
|
39
|
+
fee=payload.get("fee"),
|
|
40
|
+
net=payload.get("net"),
|
|
41
|
+
customer_debit_amount=payload.get("customer_debit_amount"),
|
|
42
|
+
amount_refunded=payload.get("amount_refunded"),
|
|
43
|
+
customer_phone_masked=payload.get("customer_phone_masked"),
|
|
44
|
+
channel=payload.get("channel"),
|
|
45
|
+
fail_reason=payload.get("fail_reason"),
|
|
46
|
+
return_url=payload.get("return_url"),
|
|
47
|
+
metadata=payload.get("metadata") or {},
|
|
48
|
+
expires_at=parse_datetime(payload.get("expires_at")),
|
|
49
|
+
confirmed_at=parse_datetime(payload.get("confirmed_at")),
|
|
50
|
+
pay_url=payload.get("pay_url"),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_refund(payload: Dict[str, Any]) -> Refund:
|
|
55
|
+
return Refund(
|
|
56
|
+
id=payload["id"],
|
|
57
|
+
charge=payload["charge"],
|
|
58
|
+
amount=payload["amount"],
|
|
59
|
+
currency=payload["currency"],
|
|
60
|
+
status=payload["status"],
|
|
61
|
+
livemode=payload["livemode"],
|
|
62
|
+
created_at=parse_required_datetime(payload["created_at"]),
|
|
63
|
+
reason=payload.get("reason"),
|
|
64
|
+
description=payload.get("description"),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_charge_list(payload: Dict[str, Any]) -> ChargeList:
|
|
69
|
+
return ChargeList(
|
|
70
|
+
charges=[parse_charge(item) for item in payload.get("charges", [])],
|
|
71
|
+
has_more=bool(payload.get("has_more")),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_payout(payload: Dict[str, Any]) -> Payout:
|
|
76
|
+
return Payout(
|
|
77
|
+
id=payload["id"],
|
|
78
|
+
recipient_phone=payload["recipient_phone"],
|
|
79
|
+
currency=payload["currency"],
|
|
80
|
+
amount=payload["amount"],
|
|
81
|
+
fee=payload["fee"],
|
|
82
|
+
status=payload["status"],
|
|
83
|
+
livemode=payload["livemode"],
|
|
84
|
+
created_at=parse_required_datetime(payload["created_at"]),
|
|
85
|
+
recipient_name=payload.get("recipient_name"),
|
|
86
|
+
fail_reason=payload.get("fail_reason"),
|
|
87
|
+
reference=payload.get("reference"),
|
|
88
|
+
metadata=payload.get("metadata") or {},
|
|
89
|
+
completed_at=parse_datetime(payload.get("completed_at")),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parse_payout_list(payload: Dict[str, Any]) -> PayoutList:
|
|
94
|
+
return PayoutList(
|
|
95
|
+
payouts=[parse_payout(item) for item in payload.get("payouts", [])],
|
|
96
|
+
has_more=payload.get("has_more", False),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def parse_event(payload: Dict[str, Any]) -> Event:
|
|
101
|
+
data = payload.get("data") or {}
|
|
102
|
+
charge = data.get("charge")
|
|
103
|
+
refund = data.get("refund")
|
|
104
|
+
payout = data.get("payout")
|
|
105
|
+
|
|
106
|
+
# Parse only the object the event actually carries. Reading data["charge"]
|
|
107
|
+
# unconditionally raised KeyError on every payout.* event.
|
|
108
|
+
return Event(
|
|
109
|
+
id=payload["id"],
|
|
110
|
+
type=payload["type"],
|
|
111
|
+
api_version=payload["api_version"],
|
|
112
|
+
created_at=parse_required_datetime(payload["created_at"]),
|
|
113
|
+
livemode=payload["livemode"],
|
|
114
|
+
data=EventData(
|
|
115
|
+
object=data.get("object", "charge"),
|
|
116
|
+
charge=parse_charge(charge) if charge else None,
|
|
117
|
+
refund=parse_refund(refund) if refund else None,
|
|
118
|
+
payout=parse_payout(payout) if payout else None,
|
|
119
|
+
),
|
|
120
|
+
)
|
wishop/_transport.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from .errors import APIConnectionError, error_for
|
|
8
|
+
|
|
9
|
+
DEFAULT_BASE_URL = "https://gatewayapi.wishopht.com"
|
|
10
|
+
DEFAULT_TIMEOUT = 30.0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Transport:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
api_key: str,
|
|
17
|
+
base_url: str,
|
|
18
|
+
timeout: float,
|
|
19
|
+
user_agent: str,
|
|
20
|
+
session: Optional[requests.Session],
|
|
21
|
+
) -> None:
|
|
22
|
+
self._api_key = api_key
|
|
23
|
+
self._base_url = base_url.rstrip("/")
|
|
24
|
+
self._timeout = timeout
|
|
25
|
+
self._user_agent = user_agent
|
|
26
|
+
self._session = session or requests.Session()
|
|
27
|
+
|
|
28
|
+
def request(
|
|
29
|
+
self,
|
|
30
|
+
method: str,
|
|
31
|
+
path: str,
|
|
32
|
+
params: Optional[Dict[str, Any]] = None,
|
|
33
|
+
body: Optional[Dict[str, Any]] = None,
|
|
34
|
+
idempotency_key: Optional[str] = None,
|
|
35
|
+
) -> Any:
|
|
36
|
+
headers = {
|
|
37
|
+
"Authorization": "Bearer " + self._api_key,
|
|
38
|
+
"Accept": "application/json",
|
|
39
|
+
"User-Agent": self._user_agent,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if idempotency_key is not None:
|
|
43
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
response = self._session.request(
|
|
47
|
+
method,
|
|
48
|
+
self._base_url + path,
|
|
49
|
+
params=params or None,
|
|
50
|
+
json=body,
|
|
51
|
+
headers=headers,
|
|
52
|
+
timeout=self._timeout,
|
|
53
|
+
)
|
|
54
|
+
except requests.RequestException as exc:
|
|
55
|
+
raise APIConnectionError(str(exc)) from exc
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
envelope = response.json()
|
|
59
|
+
except ValueError:
|
|
60
|
+
envelope = {}
|
|
61
|
+
|
|
62
|
+
if not response.ok:
|
|
63
|
+
message = envelope.get("message") or response.reason or "request failed"
|
|
64
|
+
raise error_for(response.status_code, message, _retry_after(response))
|
|
65
|
+
|
|
66
|
+
return envelope.get("data")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _retry_after(response: requests.Response) -> Optional[int]:
|
|
70
|
+
try:
|
|
71
|
+
return int(response.headers["Retry-After"])
|
|
72
|
+
except (KeyError, TypeError, ValueError):
|
|
73
|
+
return None
|
wishop/client.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from ._transport import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, Transport
|
|
8
|
+
from .resources.charges import Charges
|
|
9
|
+
from .resources.payouts import Payouts
|
|
10
|
+
from .resources.refunds import Refunds
|
|
11
|
+
|
|
12
|
+
VERSION = "1.2.0"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Wishop:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
api_key: str,
|
|
19
|
+
*,
|
|
20
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
21
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
22
|
+
session: Optional[requests.Session] = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
transport = Transport(
|
|
25
|
+
api_key=api_key,
|
|
26
|
+
base_url=base_url,
|
|
27
|
+
timeout=timeout,
|
|
28
|
+
user_agent="wishop-python/" + VERSION,
|
|
29
|
+
session=session,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
self.charges = Charges(transport)
|
|
33
|
+
self.refunds = Refunds(transport)
|
|
34
|
+
self.payouts = Payouts(transport)
|
wishop/errors.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class WishopError(Exception):
|
|
7
|
+
def __init__(self, message: str, status_code: Optional[int] = None) -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.message = message
|
|
10
|
+
self.status_code = status_code
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ValidationError(WishopError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthenticationError(WishopError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PermissionError(WishopError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NotFoundError(WishopError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConflictError(WishopError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ChargeExpiredError(WishopError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PayloadTooLargeError(WishopError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class UnprocessableError(WishopError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RateLimitError(WishopError):
|
|
46
|
+
def __init__(self, message: str, status_code: int, retry_after: Optional[int] = None) -> None:
|
|
47
|
+
super().__init__(message, status_code)
|
|
48
|
+
self.retry_after = retry_after
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class APIError(WishopError):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class APIConnectionError(WishopError):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SignatureVerificationError(WishopError):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_BY_STATUS = {
|
|
64
|
+
400: ValidationError,
|
|
65
|
+
401: AuthenticationError,
|
|
66
|
+
403: PermissionError,
|
|
67
|
+
404: NotFoundError,
|
|
68
|
+
409: ConflictError,
|
|
69
|
+
410: ChargeExpiredError,
|
|
70
|
+
413: PayloadTooLargeError,
|
|
71
|
+
422: UnprocessableError,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def error_for(status_code: int, message: str, retry_after: Optional[int] = None) -> WishopError:
|
|
76
|
+
if status_code == 429:
|
|
77
|
+
return RateLimitError(message, status_code, retry_after)
|
|
78
|
+
|
|
79
|
+
return _BY_STATUS.get(status_code, APIError)(message, status_code)
|
wishop/py.typed
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from .._parsing import parse_charge, parse_charge_list
|
|
6
|
+
from .._transport import Transport
|
|
7
|
+
from ..types import Charge, ChargeList
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Charges:
|
|
11
|
+
def __init__(self, transport: Transport) -> None:
|
|
12
|
+
self._transport = transport
|
|
13
|
+
|
|
14
|
+
def create(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
amount: str,
|
|
18
|
+
currency: str,
|
|
19
|
+
order_id: Optional[str] = None,
|
|
20
|
+
description: Optional[str] = None,
|
|
21
|
+
fee_bearer: Optional[str] = None,
|
|
22
|
+
return_url: Optional[str] = None,
|
|
23
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
24
|
+
idempotency_key: Optional[str] = None,
|
|
25
|
+
) -> Charge:
|
|
26
|
+
body: Dict[str, Any] = {"amount": amount, "currency": currency}
|
|
27
|
+
|
|
28
|
+
if order_id is not None:
|
|
29
|
+
body["order_id"] = order_id
|
|
30
|
+
|
|
31
|
+
if description is not None:
|
|
32
|
+
body["description"] = description
|
|
33
|
+
|
|
34
|
+
if fee_bearer is not None:
|
|
35
|
+
body["fee_bearer"] = fee_bearer
|
|
36
|
+
|
|
37
|
+
if return_url is not None:
|
|
38
|
+
body["return_url"] = return_url
|
|
39
|
+
|
|
40
|
+
if metadata is not None:
|
|
41
|
+
body["metadata"] = metadata
|
|
42
|
+
|
|
43
|
+
payload = self._transport.request(
|
|
44
|
+
"POST",
|
|
45
|
+
"/v1/charges",
|
|
46
|
+
body=body,
|
|
47
|
+
idempotency_key=idempotency_key,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return parse_charge(payload)
|
|
51
|
+
|
|
52
|
+
def retrieve(self, charge_id: str) -> Charge:
|
|
53
|
+
payload = self._transport.request("GET", "/v1/charges/" + charge_id)
|
|
54
|
+
|
|
55
|
+
return parse_charge(payload)
|
|
56
|
+
|
|
57
|
+
def list(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
limit: Optional[int] = None,
|
|
61
|
+
starting_after: Optional[str] = None,
|
|
62
|
+
status: Optional[str] = None,
|
|
63
|
+
query: Optional[str] = None,
|
|
64
|
+
) -> ChargeList:
|
|
65
|
+
params: Dict[str, Any] = {}
|
|
66
|
+
|
|
67
|
+
if limit is not None:
|
|
68
|
+
params["limit"] = limit
|
|
69
|
+
|
|
70
|
+
if starting_after is not None:
|
|
71
|
+
params["starting_after"] = starting_after
|
|
72
|
+
|
|
73
|
+
if status is not None:
|
|
74
|
+
params["status"] = status
|
|
75
|
+
|
|
76
|
+
if query is not None:
|
|
77
|
+
params["q"] = query
|
|
78
|
+
|
|
79
|
+
payload = self._transport.request("GET", "/v1/charges", params=params)
|
|
80
|
+
|
|
81
|
+
return parse_charge_list(payload)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from .._parsing import parse_payout, parse_payout_list
|
|
6
|
+
from .._transport import Transport
|
|
7
|
+
from ..types import Payout, PayoutList
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Payouts:
|
|
11
|
+
def __init__(self, transport: Transport) -> None:
|
|
12
|
+
self._transport = transport
|
|
13
|
+
|
|
14
|
+
def create(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
amount: str,
|
|
18
|
+
currency: str,
|
|
19
|
+
recipient_phone: str,
|
|
20
|
+
idempotency_key: str,
|
|
21
|
+
reference: Optional[str] = None,
|
|
22
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
23
|
+
) -> Payout:
|
|
24
|
+
"""Send money from your wallet to a customer's.
|
|
25
|
+
|
|
26
|
+
``idempotency_key`` has no default, unlike everywhere else in this
|
|
27
|
+
client: a payout pushes money out, so a retry after a dropped
|
|
28
|
+
connection is indistinguishable from a second, deliberate payment.
|
|
29
|
+
Derive it from the thing being paid for and reuse it across retries — a
|
|
30
|
+
key generated per attempt defeats the mechanism entirely.
|
|
31
|
+
|
|
32
|
+
Three outcomes. The returned payout's ``status`` is ``SUCCEEDED`` (the
|
|
33
|
+
money moved) or ``PENDING`` (the wallet's answer was lost — it resolves
|
|
34
|
+
on its own and a webhook reports the outcome; never send a
|
|
35
|
+
replacement). A decline raises ``UnprocessableError`` and still records
|
|
36
|
+
a ``FAILED`` payout you can ``retrieve``.
|
|
37
|
+
|
|
38
|
+
There is no recipient name parameter: the name is resolved from the
|
|
39
|
+
wallet and returned on the payout.
|
|
40
|
+
"""
|
|
41
|
+
body: Dict[str, Any] = {
|
|
42
|
+
"amount": amount,
|
|
43
|
+
"currency": currency,
|
|
44
|
+
"recipient_phone": recipient_phone,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if reference is not None:
|
|
48
|
+
body["reference"] = reference
|
|
49
|
+
|
|
50
|
+
if metadata is not None:
|
|
51
|
+
body["metadata"] = metadata
|
|
52
|
+
|
|
53
|
+
payload = self._transport.request(
|
|
54
|
+
"POST",
|
|
55
|
+
"/v1/payouts",
|
|
56
|
+
body=body,
|
|
57
|
+
idempotency_key=idempotency_key,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return parse_payout(payload)
|
|
61
|
+
|
|
62
|
+
def retrieve(self, payout_id: str) -> Payout:
|
|
63
|
+
"""Read one payout — how you check on a pending one, or read a decline."""
|
|
64
|
+
payload = self._transport.request("GET", "/v1/payouts/" + payout_id)
|
|
65
|
+
|
|
66
|
+
return parse_payout(payload)
|
|
67
|
+
|
|
68
|
+
def list(
|
|
69
|
+
self,
|
|
70
|
+
*,
|
|
71
|
+
limit: Optional[int] = None,
|
|
72
|
+
starting_after: Optional[str] = None,
|
|
73
|
+
status: Optional[str] = None,
|
|
74
|
+
currency: Optional[str] = None,
|
|
75
|
+
) -> PayoutList:
|
|
76
|
+
params: Dict[str, Any] = {}
|
|
77
|
+
|
|
78
|
+
if limit is not None:
|
|
79
|
+
params["limit"] = limit
|
|
80
|
+
|
|
81
|
+
if starting_after is not None:
|
|
82
|
+
params["starting_after"] = starting_after
|
|
83
|
+
|
|
84
|
+
if status is not None:
|
|
85
|
+
params["status"] = status
|
|
86
|
+
|
|
87
|
+
if currency is not None:
|
|
88
|
+
params["currency"] = currency
|
|
89
|
+
|
|
90
|
+
payload = self._transport.request("GET", "/v1/payouts", params=params)
|
|
91
|
+
|
|
92
|
+
return parse_payout_list(payload)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from .._parsing import parse_refund
|
|
6
|
+
from .._transport import Transport
|
|
7
|
+
from ..types import Refund
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Refunds:
|
|
11
|
+
def __init__(self, transport: Transport) -> None:
|
|
12
|
+
self._transport = transport
|
|
13
|
+
|
|
14
|
+
def create(
|
|
15
|
+
self,
|
|
16
|
+
charge_id: str,
|
|
17
|
+
*,
|
|
18
|
+
amount: Optional[str] = None,
|
|
19
|
+
reason: Optional[str] = None,
|
|
20
|
+
description: Optional[str] = None,
|
|
21
|
+
idempotency_key: Optional[str] = None,
|
|
22
|
+
) -> Refund:
|
|
23
|
+
body: Dict[str, Any] = {}
|
|
24
|
+
|
|
25
|
+
if amount is not None:
|
|
26
|
+
body["amount"] = amount
|
|
27
|
+
|
|
28
|
+
if reason is not None:
|
|
29
|
+
body["reason"] = reason
|
|
30
|
+
|
|
31
|
+
if description is not None:
|
|
32
|
+
body["description"] = description
|
|
33
|
+
|
|
34
|
+
payload = self._transport.request(
|
|
35
|
+
"POST",
|
|
36
|
+
"/v1/charges/" + charge_id + "/refunds",
|
|
37
|
+
body=body,
|
|
38
|
+
idempotency_key=idempotency_key,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return parse_refund(payload)
|
|
42
|
+
|
|
43
|
+
def list(self, charge_id: str) -> List[Refund]:
|
|
44
|
+
payload = self._transport.request("GET", "/v1/charges/" + charge_id + "/refunds")
|
|
45
|
+
|
|
46
|
+
return [parse_refund(item) for item in payload.get("refunds", [])]
|
wishop/types.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
CHARGE_STATUS_INITIATED = "INITIATED"
|
|
8
|
+
CHARGE_STATUS_OTP_SENT = "OTP_SENT"
|
|
9
|
+
CHARGE_STATUS_PAID = "PAID"
|
|
10
|
+
CHARGE_STATUS_FAILED = "FAILED"
|
|
11
|
+
CHARGE_STATUS_EXPIRED = "EXPIRED"
|
|
12
|
+
CHARGE_STATUS_REFUNDED = "REFUNDED"
|
|
13
|
+
|
|
14
|
+
FAIL_REASON_INSUFFICIENT_FUNDS = "INSUFFICIENT_FUNDS"
|
|
15
|
+
FAIL_REASON_PIN_LOCKED = "PIN_LOCKED"
|
|
16
|
+
FAIL_REASON_OTP_EXPIRED = "OTP_EXPIRED"
|
|
17
|
+
FAIL_REASON_OTP_ATTEMPTS_EXCEEDED = "OTP_ATTEMPTS_EXCEEDED"
|
|
18
|
+
FAIL_REASON_WALLET_NOT_FOUND = "WALLET_NOT_FOUND"
|
|
19
|
+
FAIL_REASON_DECLINED = "DECLINED"
|
|
20
|
+
FAIL_REASON_TIMEOUT = "TIMEOUT"
|
|
21
|
+
FAIL_REASON_INTERNAL_ERROR = "INTERNAL_ERROR"
|
|
22
|
+
|
|
23
|
+
FEE_BEARER_MERCHANT = "merchant"
|
|
24
|
+
FEE_BEARER_CUSTOMER = "customer"
|
|
25
|
+
|
|
26
|
+
REFUND_STATUS_PENDING = "pending"
|
|
27
|
+
REFUND_STATUS_SUCCEEDED = "succeeded"
|
|
28
|
+
REFUND_STATUS_FAILED = "failed"
|
|
29
|
+
|
|
30
|
+
REFUND_REASON_REQUESTED_BY_CUSTOMER = "requested_by_customer"
|
|
31
|
+
REFUND_REASON_DUPLICATE = "duplicate"
|
|
32
|
+
REFUND_REASON_FRAUDULENT = "fraudulent"
|
|
33
|
+
REFUND_REASON_OTHER = "other"
|
|
34
|
+
|
|
35
|
+
EVENT_CHARGE_PAID = "charge.paid"
|
|
36
|
+
EVENT_CHARGE_FAILED = "charge.failed"
|
|
37
|
+
EVENT_CHARGE_EXPIRED = "charge.expired"
|
|
38
|
+
EVENT_CHARGE_REFUNDED = "charge.refunded"
|
|
39
|
+
|
|
40
|
+
EVENT_PAYOUT_CREATED = "payout.created"
|
|
41
|
+
EVENT_PAYOUT_SUCCEEDED = "payout.succeeded"
|
|
42
|
+
EVENT_PAYOUT_FAILED = "payout.failed"
|
|
43
|
+
|
|
44
|
+
PAYOUT_STATUS_PENDING = "PENDING"
|
|
45
|
+
PAYOUT_STATUS_SUCCEEDED = "SUCCEEDED"
|
|
46
|
+
PAYOUT_STATUS_FAILED = "FAILED"
|
|
47
|
+
|
|
48
|
+
# Payout-only fail reasons. FAIL_REASON_WALLET_NOT_FOUND is shared with charges.
|
|
49
|
+
FAIL_REASON_INSUFFICIENT_MERCHANT_BALANCE = "INSUFFICIENT_MERCHANT_BALANCE"
|
|
50
|
+
FAIL_REASON_RECIPIENT_INELIGIBLE = "RECIPIENT_INELIGIBLE"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Charge:
|
|
55
|
+
id: str
|
|
56
|
+
status: str
|
|
57
|
+
currency: str
|
|
58
|
+
amount: str
|
|
59
|
+
livemode: bool
|
|
60
|
+
created_at: datetime
|
|
61
|
+
order_id: Optional[str] = None
|
|
62
|
+
description: Optional[str] = None
|
|
63
|
+
fee_bearer: Optional[str] = None
|
|
64
|
+
fee: Optional[str] = None
|
|
65
|
+
net: Optional[str] = None
|
|
66
|
+
customer_debit_amount: Optional[str] = None
|
|
67
|
+
amount_refunded: Optional[str] = None
|
|
68
|
+
customer_phone_masked: Optional[str] = None
|
|
69
|
+
# "api" (created by you) or "app" (a customer paid your pay code in the app).
|
|
70
|
+
channel: Optional[str] = None
|
|
71
|
+
fail_reason: Optional[str] = None
|
|
72
|
+
return_url: Optional[str] = None
|
|
73
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
74
|
+
expires_at: Optional[datetime] = None
|
|
75
|
+
confirmed_at: Optional[datetime] = None
|
|
76
|
+
pay_url: Optional[str] = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class Refund:
|
|
81
|
+
id: str
|
|
82
|
+
charge: str
|
|
83
|
+
amount: str
|
|
84
|
+
currency: str
|
|
85
|
+
status: str
|
|
86
|
+
livemode: bool
|
|
87
|
+
created_at: datetime
|
|
88
|
+
reason: Optional[str] = None
|
|
89
|
+
description: Optional[str] = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class ChargeList:
|
|
94
|
+
charges: List[Charge]
|
|
95
|
+
has_more: bool
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class Payout:
|
|
100
|
+
"""Money sent from your wallet to a customer's.
|
|
101
|
+
|
|
102
|
+
``status`` of ``PAYOUT_STATUS_PENDING`` does not mean "queued": the wallet's
|
|
103
|
+
answer was lost, so the money may or may not have moved. It resolves on its
|
|
104
|
+
own and a ``payout.succeeded`` or ``payout.failed`` webhook reports the
|
|
105
|
+
outcome. Never send a replacement payout for a pending one.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
id: str
|
|
109
|
+
recipient_phone: str
|
|
110
|
+
currency: str
|
|
111
|
+
amount: str
|
|
112
|
+
# Charged on top of amount: your wallet is debited amount + fee.
|
|
113
|
+
fee: str
|
|
114
|
+
status: str
|
|
115
|
+
livemode: bool
|
|
116
|
+
created_at: datetime
|
|
117
|
+
recipient_name: Optional[str] = None
|
|
118
|
+
fail_reason: Optional[str] = None
|
|
119
|
+
reference: Optional[str] = None
|
|
120
|
+
# Values may be any JSON type, unlike a charge's string map.
|
|
121
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
122
|
+
completed_at: Optional[datetime] = None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class PayoutList:
|
|
127
|
+
payouts: List[Payout]
|
|
128
|
+
has_more: bool
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class EventData:
|
|
133
|
+
"""The object an event is about.
|
|
134
|
+
|
|
135
|
+
``object`` says which — ``"charge"`` on charge.* events, ``"payout"`` on
|
|
136
|
+
payout.* ones — and only the matching field is set. ``charge`` became
|
|
137
|
+
optional in 1.1.0: a payout event carries none, and reading it
|
|
138
|
+
unconditionally used to raise KeyError.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
object: str
|
|
142
|
+
charge: Optional[Charge] = None
|
|
143
|
+
refund: Optional[Refund] = None
|
|
144
|
+
payout: Optional[Payout] = None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class Event:
|
|
149
|
+
id: str
|
|
150
|
+
type: str
|
|
151
|
+
api_version: str
|
|
152
|
+
created_at: datetime
|
|
153
|
+
livemode: bool
|
|
154
|
+
data: EventData
|
wishop/webhooks.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import List, Tuple, Union
|
|
8
|
+
|
|
9
|
+
from ._parsing import parse_event
|
|
10
|
+
from .errors import SignatureVerificationError
|
|
11
|
+
from .types import Event
|
|
12
|
+
|
|
13
|
+
DEFAULT_TOLERANCE = 300
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def construct_event(
|
|
17
|
+
payload: Union[bytes, str],
|
|
18
|
+
signature_header: str,
|
|
19
|
+
secret: str,
|
|
20
|
+
tolerance: int = DEFAULT_TOLERANCE,
|
|
21
|
+
) -> Event:
|
|
22
|
+
raw = payload.encode("utf-8") if isinstance(payload, str) else payload
|
|
23
|
+
timestamp, signatures = _parse_signature_header(signature_header)
|
|
24
|
+
|
|
25
|
+
if tolerance > 0 and abs(time.time() - timestamp) > tolerance:
|
|
26
|
+
raise SignatureVerificationError("timestamp outside the tolerance window")
|
|
27
|
+
|
|
28
|
+
expected = hmac.new(
|
|
29
|
+
secret.encode("utf-8"),
|
|
30
|
+
str(timestamp).encode("utf-8") + b"." + raw,
|
|
31
|
+
hashlib.sha256,
|
|
32
|
+
).hexdigest()
|
|
33
|
+
|
|
34
|
+
if not any(hmac.compare_digest(expected, signature) for signature in signatures):
|
|
35
|
+
raise SignatureVerificationError("no signature matched the signing secret")
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
decoded = json.loads(raw)
|
|
39
|
+
except ValueError as exc:
|
|
40
|
+
raise SignatureVerificationError("payload is not valid JSON") from exc
|
|
41
|
+
|
|
42
|
+
return parse_event(decoded)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_signature_header(header: str) -> Tuple[int, List[str]]:
|
|
46
|
+
timestamp = None
|
|
47
|
+
signatures = []
|
|
48
|
+
|
|
49
|
+
for part in header.split(","):
|
|
50
|
+
key, separator, value = part.strip().partition("=")
|
|
51
|
+
|
|
52
|
+
if not separator:
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
if key == "t":
|
|
56
|
+
try:
|
|
57
|
+
timestamp = int(value)
|
|
58
|
+
except ValueError:
|
|
59
|
+
raise SignatureVerificationError("malformed timestamp") from None
|
|
60
|
+
elif key == "v1" and value:
|
|
61
|
+
signatures.append(value)
|
|
62
|
+
|
|
63
|
+
if timestamp is None:
|
|
64
|
+
raise SignatureVerificationError("no timestamp in signature header")
|
|
65
|
+
|
|
66
|
+
if not signatures:
|
|
67
|
+
raise SignatureVerificationError("no v1 signature in signature header")
|
|
68
|
+
|
|
69
|
+
return timestamp, signatures
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: wishop
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: The official Python client for the Wishop API
|
|
5
|
+
Project-URL: Documentation, https://developers.wishopht.com
|
|
6
|
+
Project-URL: Source, https://github.com/wishopht/wishop-python
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2026 Wishop
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
30
|
+
Classifier: Programming Language :: Python :: 3
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
36
|
+
Classifier: Typing :: Typed
|
|
37
|
+
Requires-Python: >=3.9
|
|
38
|
+
Requires-Dist: requests>=2.25
|
|
39
|
+
Provides-Extra: dev
|
|
40
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
41
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
42
|
+
Requires-Dist: responses>=0.23; extra == 'dev'
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
# Wishop Python
|
|
46
|
+
|
|
47
|
+
The official Python client for the [Wishop API](https://developers.wishopht.com).
|
|
48
|
+
|
|
49
|
+
Create charges, send customers to a hosted checkout page, refund them, and verify webhooks.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install wishop
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Requires Python 3.9 or later.
|
|
58
|
+
|
|
59
|
+
## Quickstart
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import os
|
|
63
|
+
|
|
64
|
+
from wishop import Wishop
|
|
65
|
+
|
|
66
|
+
client = Wishop(os.environ["WISHOP_SECRET_KEY"])
|
|
67
|
+
|
|
68
|
+
charge = client.charges.create(
|
|
69
|
+
amount="1500.00",
|
|
70
|
+
currency="HTG",
|
|
71
|
+
order_id="order_1042",
|
|
72
|
+
description="Two large coffees",
|
|
73
|
+
return_url="https://yourstore.com/orders/1042/complete",
|
|
74
|
+
metadata={"cart_id": "c_88213"},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
print(charge.pay_url)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Send the customer to `charge.pay_url`. It is returned on create only, so persist it with the order.
|
|
81
|
+
|
|
82
|
+
When the payment reaches a terminal state, Wishop sends a webhook and returns the customer to your `return_url`. Confirm the outcome from the webhook or by retrieving the charge — never from the redirect alone.
|
|
83
|
+
|
|
84
|
+
## Configuration
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
client = Wishop(
|
|
88
|
+
os.environ["WISHOP_SECRET_KEY"],
|
|
89
|
+
timeout=10.0,
|
|
90
|
+
session=my_session,
|
|
91
|
+
base_url="http://localhost:8080",
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
| Argument | Default | Notes |
|
|
96
|
+
| --- | --- | --- |
|
|
97
|
+
| `timeout` | `30.0` | Seconds, applied to every request |
|
|
98
|
+
| `session` | a new `requests.Session` | Supply your own for proxies, retries at the transport layer, or custom TLS |
|
|
99
|
+
| `base_url` | `https://gatewayapi.wishopht.com` | For pointing at a mock server in your own tests |
|
|
100
|
+
|
|
101
|
+
Test and live mode are selected by the key, not by configuration. A `sk_test_` key reaches test data; a `sk_live_` key reaches live data.
|
|
102
|
+
|
|
103
|
+
The client is synchronous. From an async framework, run calls in a worker thread so the event loop is not blocked:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
charge = await asyncio.to_thread(client.charges.create, amount="1500.00", currency="HTG")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Charges
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
charge = client.charges.create(amount="1500.00", currency="HTG")
|
|
113
|
+
charge = client.charges.retrieve("ch_3kQ8xR2mVn7pLdYt")
|
|
114
|
+
page = client.charges.list(status="PAID", limit=100)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`list` returns one page. Pass the id of the last charge as `starting_after` while `has_more` is true:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
def all_charges(client, status=None):
|
|
121
|
+
starting_after = None
|
|
122
|
+
|
|
123
|
+
while True:
|
|
124
|
+
page = client.charges.list(limit=100, status=status, starting_after=starting_after)
|
|
125
|
+
|
|
126
|
+
for charge in page.charges:
|
|
127
|
+
yield charge
|
|
128
|
+
|
|
129
|
+
if not page.has_more:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
starting_after = page.charges[-1].id
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Search across charge id and order id with `query`, which maps to the API's `q` parameter.
|
|
136
|
+
|
|
137
|
+
## Refunds
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
refund = client.refunds.create(
|
|
141
|
+
"ch_3kQ8xR2mVn7pLdYt",
|
|
142
|
+
amount="500.00",
|
|
143
|
+
reason="requested_by_customer",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
refunds = client.refunds.list("ch_3kQ8xR2mVn7pLdYt")
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Omit `amount` to refund everything still refundable. Refund lists are not paginated.
|
|
150
|
+
|
|
151
|
+
## Payouts
|
|
152
|
+
|
|
153
|
+
Sends money from your wallet to a customer's — payroll, marketplace settlements, rewards.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
payout = client.payouts.create(
|
|
157
|
+
amount="150.00",
|
|
158
|
+
currency="HTG",
|
|
159
|
+
recipient_phone="+50931234567",
|
|
160
|
+
reference="June payroll",
|
|
161
|
+
idempotency_key=f"payroll-2026-06-{employee.id}",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
payouts = client.payouts.list(status="FAILED")
|
|
165
|
+
one = client.payouts.retrieve("po_7Hn2vB9xQm4tRkLw")
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`idempotency_key` has **no default** here, unlike everywhere else in this client: a payout pushes money out, so an un-keyed retry after a dropped connection is indistinguishable from a second payment. Your wallet is debited `amount + fee`.
|
|
169
|
+
|
|
170
|
+
Three outcomes, and the middle one is the one to handle:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
if payout.status == wishop.PAYOUT_STATUS_SUCCEEDED:
|
|
174
|
+
... # the money moved
|
|
175
|
+
elif payout.status == wishop.PAYOUT_STATUS_PENDING:
|
|
176
|
+
# The wallet's answer was lost — the money may or may not have moved.
|
|
177
|
+
# It resolves on its own and a webhook reports the outcome.
|
|
178
|
+
# Never send a replacement; retry with the SAME key if you must.
|
|
179
|
+
...
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
A decline raises `UnprocessableError` and still records a `FAILED` payout you can `retrieve` — unlike a rejected refund, which leaves nothing behind.
|
|
183
|
+
|
|
184
|
+
## Money
|
|
185
|
+
|
|
186
|
+
Amounts are strings, never numbers, and every field that holds money is a string: `amount`, `fee`, `net`, `customer_debit_amount`, `amount_refunded`. Parse them with `Decimal`, never a float.
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from decimal import Decimal
|
|
190
|
+
|
|
191
|
+
amount = Decimal(charge.amount)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Idempotency
|
|
195
|
+
|
|
196
|
+
Pass a key on charge and refund creation so a retry after a timeout cannot charge twice:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
charge = client.charges.create(
|
|
200
|
+
amount="1500.00",
|
|
201
|
+
currency="HTG",
|
|
202
|
+
idempotency_key=f"charge-{order.id}-{order.payment_attempt}",
|
|
203
|
+
)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
On charges and refunds nothing is sent unless you pass it. **Payouts are the exception: `idempotency_key` is a required keyword argument**, so omitting it is a `TypeError` rather than a rejected request.
|
|
207
|
+
|
|
208
|
+
Derive a charge key from the payment *attempt*, not the order — reusing an order id replays the previous failure instead of creating the charge a retrying customer needs. Derive a payout key the opposite way, from the payment itself, and reuse it across retries: a fresh key on a payout retry sends a second payment. Keys are remembered for 24 hours.
|
|
209
|
+
|
|
210
|
+
## Errors
|
|
211
|
+
|
|
212
|
+
Every failure raises a typed exception:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
from wishop import RateLimitError, UnprocessableError, WishopError
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
charge = client.charges.create(amount="1500.00", currency="HTG")
|
|
219
|
+
except RateLimitError as exc:
|
|
220
|
+
time.sleep(exc.retry_after or 1)
|
|
221
|
+
except UnprocessableError as exc:
|
|
222
|
+
log.warning(exc.message)
|
|
223
|
+
except WishopError as exc:
|
|
224
|
+
log.error("wishop failed with %s", exc.status_code)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
| Exception | Status | Cause |
|
|
228
|
+
| --- | --- | --- |
|
|
229
|
+
| `ValidationError` | 400 | Malformed body, invalid amount or currency, metadata over the limits |
|
|
230
|
+
| `AuthenticationError` | 401 | Missing, malformed, unknown, or revoked key |
|
|
231
|
+
| `PermissionError` | 403 | Live key from an address that is not whitelisted |
|
|
232
|
+
| `NotFoundError` | 404 | No charge, refund or payout with that id in this mode; or no wallet for a payout recipient's phone |
|
|
233
|
+
| `ConflictError` | 409 | Idempotency key still in flight, charge no longer payable, or wallet not provisioned for live payouts |
|
|
234
|
+
| `ChargeExpiredError` | 410 | Charge expired |
|
|
235
|
+
| `PayloadTooLargeError` | 413 | Upload too large |
|
|
236
|
+
| `UnprocessableError` | 422 | Currency not enabled, charge not refundable, refund exceeds remaining, wallet declined, or a payout declined for balance or recipient |
|
|
237
|
+
| `RateLimitError` | 429 | Rate limit exceeded; carries `retry_after` in seconds |
|
|
238
|
+
| `APIError` | 5xx and anything unmapped | Failure on the gateway side |
|
|
239
|
+
| `APIConnectionError` | — | The request never completed: DNS, TLS, connection reset, or timeout |
|
|
240
|
+
|
|
241
|
+
All of them subclass `WishopError`, which carries `.status_code` and `.message`.
|
|
242
|
+
|
|
243
|
+
`PermissionError` shadows the builtin of the same name when imported bare — import it as `from wishop import PermissionError as WishopPermissionError` if that matters in your module.
|
|
244
|
+
|
|
245
|
+
`.message` is prose and its wording can change — branch on the exception type or `.status_code`, never on the text.
|
|
246
|
+
|
|
247
|
+
The client does not retry. `ConflictError`, `RateLimitError`, and `APIError` are safe to retry with backoff and the same idempotency key; nothing else is.
|
|
248
|
+
|
|
249
|
+
## Payment failures
|
|
250
|
+
|
|
251
|
+
A failed payment is not an exception — the call succeeded, the payment didn't. Read `status` and `fail_reason`:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
if charge.status == "FAILED":
|
|
255
|
+
show(EXPLANATIONS.get(charge.fail_reason, "The payment could not be completed."))
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
| `fail_reason` | Meaning |
|
|
259
|
+
| --- | --- |
|
|
260
|
+
| `INSUFFICIENT_FUNDS` | Wallet balance did not cover the payment |
|
|
261
|
+
| `PIN_LOCKED` | Wallet locked after repeated incorrect PINs |
|
|
262
|
+
| `OTP_EXPIRED` | One-time code expired before use |
|
|
263
|
+
| `OTP_ATTEMPTS_EXCEEDED` | Too many incorrect one-time codes |
|
|
264
|
+
| `WALLET_NOT_FOUND` | No wallet for that phone number |
|
|
265
|
+
| `DECLINED` | Wallet refused the payment |
|
|
266
|
+
| `TIMEOUT` | Confirmation timed out |
|
|
267
|
+
| `INTERNAL_ERROR` | Failure on our side |
|
|
268
|
+
|
|
269
|
+
Keep a default branch — new reasons can be added, and `fail_reason` is a plain string so an unknown value arrives intact rather than failing to parse.
|
|
270
|
+
|
|
271
|
+
## Webhooks
|
|
272
|
+
|
|
273
|
+
Verification needs the body **exactly as received**. `request.json` and `request.get_json()` will not do — they hand you a parsed object, and re-serializing it changes whitespace and key order so the signature will never match.
|
|
274
|
+
|
|
275
|
+
Flask:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
from wishop import SignatureVerificationError, construct_event
|
|
279
|
+
|
|
280
|
+
@app.post("/webhooks/wishop")
|
|
281
|
+
def wishop_webhook():
|
|
282
|
+
try:
|
|
283
|
+
event = construct_event(
|
|
284
|
+
request.get_data(),
|
|
285
|
+
request.headers.get("X-Wishop-Signature", ""),
|
|
286
|
+
os.environ["WISHOP_WEBHOOK_SECRET"],
|
|
287
|
+
)
|
|
288
|
+
except SignatureVerificationError:
|
|
289
|
+
return "", 400
|
|
290
|
+
|
|
291
|
+
if events.seen(request.headers["X-Webhook-Id"]):
|
|
292
|
+
return "", 200
|
|
293
|
+
|
|
294
|
+
if event.type == "charge.paid":
|
|
295
|
+
fulfil(event.data.charge)
|
|
296
|
+
elif event.type == "charge.failed":
|
|
297
|
+
notify_failure(event.data.charge)
|
|
298
|
+
elif event.type == "charge.expired":
|
|
299
|
+
release_stock(event.data.charge)
|
|
300
|
+
elif event.type == "charge.refunded":
|
|
301
|
+
record_refund(event.data.refund)
|
|
302
|
+
elif event.type == "payout.succeeded":
|
|
303
|
+
mark_payout_sent(event.data.payout)
|
|
304
|
+
elif event.type == "payout.failed":
|
|
305
|
+
mark_payout_failed(event.data.payout)
|
|
306
|
+
|
|
307
|
+
return "", 200
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Django uses `request.body`; FastAPI uses `await request.body()`.
|
|
311
|
+
|
|
312
|
+
`construct_event` rejects a stale timestamp, an altered body, and a signature that does not match. It accepts a match on any `v1` value, which is what keeps events flowing during a secret rotation. The default tolerance is 300 seconds:
|
|
313
|
+
|
|
314
|
+
```python
|
|
315
|
+
event = construct_event(body, signature, secret, tolerance=60)
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Delivery is at-least-once, so deduplicate on the `X-Webhook-Id` header, which is stable across retries. Record it in the same transaction as the work it triggers. Respond `2xx` once the event is durably recorded and queue anything slow — you have about ten seconds per attempt.
|
|
319
|
+
|
|
320
|
+
`event.data.refund` is set only on `charge.refunded`. A partial refund leaves the charge `PAID`; read `amount_refunded`.
|
|
321
|
+
|
|
322
|
+
`event.data.charge` is `None` on `payout.*` events and `event.data.payout` is `None` on `charge.*` ones — `event.data.object` says which you have.
|
|
323
|
+
|
|
324
|
+
## Testing
|
|
325
|
+
|
|
326
|
+
Test mode uses `sk_test_` keys against the same host. The outcome is chosen by a phone number — the one the customer enters for a charge, the recipient's for a payout. The two use different tables; `+50900000006` is how you exercise a pending payout. See [test phone numbers](https://developers.wishopht.com/testing/test-phone-numbers).
|
|
327
|
+
|
|
328
|
+
To test your own code against this client, point it at a local server:
|
|
329
|
+
|
|
330
|
+
```python
|
|
331
|
+
client = Wishop("sk_test_key", base_url="http://localhost:8080")
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
## Reference
|
|
335
|
+
|
|
336
|
+
Field-by-field documentation for the charge, refund, payout, and event objects lives at [developers.wishopht.com](https://developers.wishopht.com).
|
|
337
|
+
|
|
338
|
+
## License
|
|
339
|
+
|
|
340
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
wishop/__init__.py,sha256=Rqm3eVS1WIY_D_i_1QUqsyLyZCrBMjnpeJFzBEGPKQw,3132
|
|
2
|
+
wishop/_parsing.py,sha256=AUa-YYlqF5BMEX0Aijr7b7ukM0zxjOKoCxCAM8ctU60,4000
|
|
3
|
+
wishop/_transport.py,sha256=2AmuJpJi_e19Tbj6ayPdGzteTR4kEBXWsojz19x0DHk,2039
|
|
4
|
+
wishop/client.py,sha256=Ge0ZFCN9-n19lIZAkoG_RZ_5b09s4q7oaXPgJjw1EpU,856
|
|
5
|
+
wishop/errors.py,sha256=Aww-JVUkx0KpT-JZhnPTZbvQ7IwOZqmrAf2e7gP4eE8,1539
|
|
6
|
+
wishop/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
wishop/types.py,sha256=-IKS6RUczycMi2XskgXTX6g-z2OC6mimb0WcHoBZ6oA,4418
|
|
8
|
+
wishop/webhooks.py,sha256=WDzLbd5HajrYTY6DzUCa9ors73IcIH6gunGVKL6Y1nA,1976
|
|
9
|
+
wishop/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
wishop/resources/charges.py,sha256=VE3pPdHinXJiCiyA0AoXSErtcSYE3djVyMv5VctF_NY,2188
|
|
11
|
+
wishop/resources/payouts.py,sha256=ExuRWj_RPkpFqvkE0zX2MkgRliz_UpIPA26YrsBdBNY,2935
|
|
12
|
+
wishop/resources/refunds.py,sha256=84BIBEmhV2JxDZf2_XZUElFWFjr7c2t1yXdrGa9wUig,1241
|
|
13
|
+
wishop-1.2.0.dist-info/METADATA,sha256=kPO-HngjTvvOCCTK99qsJdG4bK0f_YNkXXhCFM79O84,13076
|
|
14
|
+
wishop-1.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
15
|
+
wishop-1.2.0.dist-info/licenses/LICENSE,sha256=GRjrQypCpUeMjH5aWF3-Z3oZu17mnELyQ55TQ9mzTKE,1063
|
|
16
|
+
wishop-1.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wishop
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|