wishop 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ name: test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python: ['3.9', '3.10', '3.11', '3.12', '3.13']
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python }}
19
+ - run: pip install -e ".[dev]"
20
+ - run: mypy
21
+ - run: pytest
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ build/
5
+ dist/
6
+ *.egg-info/
7
+ .pytest_cache/
@@ -0,0 +1,40 @@
1
+ # Changelog
2
+
3
+ ## 1.2.0
4
+
5
+ - `Charge.channel` (`"api"` or `"app"`). Charges started by a customer in the
6
+ WiShop app are returned by the API and carried by webhooks; the field was
7
+ previously dropped.
8
+ - `Charge.metadata` and `charges.create(metadata=...)` are typed `Dict[str, Any]`:
9
+ the API accepts any JSON value, not only strings.
10
+
11
+ ## 1.1.0
12
+
13
+ Payouts — send money from your wallet to a customer's.
14
+
15
+ - `payouts.create`, `payouts.retrieve`, `payouts.list`
16
+ - `Payout` and `PayoutList` dataclasses
17
+ - `PAYOUT_STATUS_*` constants, plus
18
+ `FAIL_REASON_INSUFFICIENT_MERCHANT_BALANCE` and
19
+ `FAIL_REASON_RECIPIENT_INELIGIBLE`
20
+ - `EVENT_PAYOUT_CREATED`, `EVENT_PAYOUT_SUCCEEDED`, `EVENT_PAYOUT_FAILED`
21
+
22
+ Fixed: `construct_event` raised `KeyError` on every `payout.*` event, because
23
+ `parse_event` read `data["charge"]` unconditionally. It now parses only the
24
+ object the event carries.
25
+
26
+ **Type change:** `EventData.charge` is now `Optional[Charge]` and
27
+ `EventData.payout` was added. Code that reads `event.data.charge` without a
28
+ `None` check needs one under mypy. Charge events are unchanged at runtime.
29
+
30
+ `payouts.create` takes `idempotency_key` as a required keyword argument — the
31
+ API requires one, and an un-keyed payout retry pays twice.
32
+
33
+ ## 1.0.0
34
+
35
+ Initial release.
36
+
37
+ - `charges.create`, `charges.retrieve`, `charges.list`
38
+ - `refunds.create`, `refunds.list`
39
+ - `construct_event` for webhook signature verification
40
+ - Typed exceptions for every documented status code
wishop-1.2.0/LICENSE ADDED
@@ -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.
wishop-1.2.0/PKG-INFO ADDED
@@ -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