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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.