qvard 0.1.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.
- qvard-0.1.0/PKG-INFO +164 -0
- qvard-0.1.0/README.md +157 -0
- qvard-0.1.0/pyproject.toml +14 -0
- qvard-0.1.0/setup.cfg +4 -0
- qvard-0.1.0/src/qvard/__init__.py +137 -0
- qvard-0.1.0/src/qvard/errors.py +67 -0
- qvard-0.1.0/src/qvard/webhooks.py +51 -0
- qvard-0.1.0/src/qvard.egg-info/PKG-INFO +164 -0
- qvard-0.1.0/src/qvard.egg-info/SOURCES.txt +11 -0
- qvard-0.1.0/src/qvard.egg-info/dependency_links.txt +1 -0
- qvard-0.1.0/src/qvard.egg-info/top_level.txt +1 -0
- qvard-0.1.0/tests/test_qvard.py +275 -0
- qvard-0.1.0/tests/test_webhooks.py +54 -0
qvard-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qvard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Qvard payments API client for Python: payments, payouts, rates and webhook signature checks
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
|
|
8
|
+
# qvard (Python)
|
|
9
|
+
|
|
10
|
+
Python client for the Qvard payments API. Zero dependencies (standard library only), Python 3.9 or newer.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
pip install qvard
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import os
|
|
22
|
+
from qvard import Qvard
|
|
23
|
+
|
|
24
|
+
qvard = Qvard(os.environ['QVARD_SECRET_KEY']) # qvard_sk_...
|
|
25
|
+
|
|
26
|
+
payment = qvard.payments.create(id='order-1001', price=10, currency='EUR')
|
|
27
|
+
# send the payer to payment['redirectUrl']
|
|
28
|
+
|
|
29
|
+
info = qvard.payments.get(payment['id'])
|
|
30
|
+
print(info['status']) # wait | partial | completed | rejected
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Options: `Qvard(secret_key, base_url='https://api.qvard.com', timeout_ms=30000)`. `timeout_ms` is in milliseconds.
|
|
34
|
+
|
|
35
|
+
## Keys
|
|
36
|
+
|
|
37
|
+
- Use the secret key `qvard_sk_...` on your server only. Never put it in browser or mobile code. A publishable key (`qvard_pk_...`) is rejected by the constructor because it is for the browser only.
|
|
38
|
+
- Legacy keys without the `qvard_sk_` prefix are rejected by the constructor. Rotate the key in the cabinet (Shop settings → API keys) to get a `qvard_sk_...` key.
|
|
39
|
+
- The SDK sends `Authorization: Bearer <key>`. It never sends `x-api-key`.
|
|
40
|
+
|
|
41
|
+
## Methods
|
|
42
|
+
|
|
43
|
+
| Call | Request | Returns |
|
|
44
|
+
| --- | --- | --- |
|
|
45
|
+
| `payments.create(id=, price=, currency=None, payment_system_id=None)` | `POST /api/v1/payment` | `{id, redirectUrl, telegramUrl?}` |
|
|
46
|
+
| `payments.get(id)` | `GET /api/v1/payment/:id` | `{id, status, createdAt, paidAt, price, currency, paidAmount, token, expiresAt}` |
|
|
47
|
+
| `payouts.create(id=, price=, method_id=, address=, comment=None)` | `POST /api/v1/payout` | `True` |
|
|
48
|
+
| `methods.info(id)` | `GET /api/v1/method/:id/info` | `{server, isReady, isActive}` |
|
|
49
|
+
| `rates.list()` | `GET /api/v1/rates` | `{base, updatedAt, rates}` |
|
|
50
|
+
|
|
51
|
+
The results are the server's `data` as dictionaries, with the server's field names (camelCase).
|
|
52
|
+
|
|
53
|
+
- `id` in `payments.create` is your own order id. The server requires it. The same id with the same amount and currency returns the existing payment. A different amount replaces an unpaid one, and the server answers `409` once money is on the way.
|
|
54
|
+
- `payments.get` answers `error.notfound` for a payment of another shop, the same as for an unknown uuid.
|
|
55
|
+
- `price` is sent as you give it. The server converts it to USD at the current rate and stores that rate with the payment. Unset optional arguments (`None`) are not sent.
|
|
56
|
+
- `convert_for_display(amount, from_, to, rates)` is exported for showing prices only. Pass `qvard.rates.list()['rates']`. The total a payer pays is computed by the server; do not charge the value this function returns. It raises `ValueError` for a currency without a rate.
|
|
57
|
+
|
|
58
|
+
## Payouts
|
|
59
|
+
|
|
60
|
+
`payouts.create` sends money out of your balance. Read this before you call it.
|
|
61
|
+
|
|
62
|
+
- The SDK never retries a payout.
|
|
63
|
+
- The result is **unknown** on a timeout, a network error, a `5xx` answer (including `502`, `503` and `504` from a gateway), or a `2xx` answer whose body is not a JSON object (an array, a string, `null` or an empty body). The error's `friendly_message` says: `Result unknown: check whether the payout went through before retrying (reuse the same id).`. A payout may have been created. In that case the error's `status` is the HTTP status and `message` is the server's key, or `HTTP <status>` when there is none.
|
|
64
|
+
- A `4xx` answer is a refusal: the payout was not created, and `message` is the reason (for example `Insufficient balance`).
|
|
65
|
+
- To retry safely, send the same `id` and the same arguments as the first attempt. See the next section.
|
|
66
|
+
|
|
67
|
+
### Payout idempotency
|
|
68
|
+
|
|
69
|
+
What protects a payout from being paid twice is the `id` field of the request body, which is your payout id.
|
|
70
|
+
|
|
71
|
+
- The server requires `id`: without it the answer is `400`.
|
|
72
|
+
- The server stores `id` on the withdrawal as its idempotency key. Before it creates a withdrawal it looks for an existing withdrawal of your shop with the same method and the same key. If it finds one, it returns that one and creates nothing.
|
|
73
|
+
- The lookup runs under a database lock for your shop and method, so two simultaneous requests with the same `id` cannot both create a payout.
|
|
74
|
+
- Scope: the key is matched within your shop and the `method_id`. The same `id` on a different method creates a second payout. The same `id` in a different shop is independent.
|
|
75
|
+
- A repeated request with the same `id` returns the original payout, and the API answers `True` exactly as it does for a new payout. The response does not say that it was a replay.
|
|
76
|
+
- A repeated request with the same `id` but a different `price`, `address` or `comment` is not compared. The original payout stands and the new values are ignored.
|
|
77
|
+
- The lookup finds only payouts that were created. A request that was rejected with `400` (for example `Insufficient balance`) created nothing, so sending it again with the same `id` is a new attempt.
|
|
78
|
+
- After a key rotation the previous key is not accepted on `POST /api/v1/payout`, even during the 24-hour grace period.
|
|
79
|
+
- v1 has no endpoint to look up a payout by your id. To check whether a payout from a timed-out call exists, look in the cabinet.
|
|
80
|
+
|
|
81
|
+
## Webhooks
|
|
82
|
+
|
|
83
|
+
Qvard calls your callback URL with a JSON body and the header `X-Qvard-Signature: t=<unix>,v1=<hex>[,v1=<hex>]`. The signature is HMAC-SHA256, keyed with your secret key, over `t + "." + body`. Two `v1` values are sent for 24 hours after a key rotation, and either one is valid.
|
|
84
|
+
|
|
85
|
+
Verify the signature over the **raw** body, before any JSON parsing. Read the body as bytes from your framework (not from a parsed form or JSON object):
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import os
|
|
89
|
+
from qvard import Qvard
|
|
90
|
+
|
|
91
|
+
secret = os.environ['QVARD_SECRET_KEY']
|
|
92
|
+
qvard = Qvard(secret)
|
|
93
|
+
|
|
94
|
+
def handle_webhook(raw_body: bytes, signature_header: str) -> int:
|
|
95
|
+
"""Returns the HTTP status to answer with."""
|
|
96
|
+
try:
|
|
97
|
+
event = qvard.webhooks.construct_event(raw_body, signature_header, secret)
|
|
98
|
+
except ValueError:
|
|
99
|
+
return 400
|
|
100
|
+
# event['id'] is YOUR order id (the one you passed to payments.create), not the payment uuid.
|
|
101
|
+
# Check event['amount'] and event['currency'] against your order before you fulfil it.
|
|
102
|
+
return 200
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- `qvard.webhooks.verify(raw_body, header, secret, tolerance_sec=300)` returns `True` or `False`. It raises `ValueError` only when no secret is given.
|
|
106
|
+
- `qvard.webhooks.construct_event(raw_body, header, secret)` returns the parsed event, or raises `ValueError` when the signature fails.
|
|
107
|
+
- `raw_body` may be `bytes` or `str`. The check accepts a timestamp within `tolerance_sec` (300 seconds) of now, in either direction, and accepts any `v1` that matches. The comparison is constant-time.
|
|
108
|
+
- The event body is `{id, status, paymentLink?, customerEmail?, amount, currency, amountUsd, rate}`. There is no event `type` field.
|
|
109
|
+
- Answer `200`. Qvard treats any other status as a failed delivery and retries it.
|
|
110
|
+
|
|
111
|
+
## Errors
|
|
112
|
+
|
|
113
|
+
Every failed call raises `QvardError`:
|
|
114
|
+
|
|
115
|
+
- `status`: the HTTP status.
|
|
116
|
+
- `message` (also `str(error)`): the server's key, for example `error.wrong.api.key`, or the text it sent.
|
|
117
|
+
- `friendly_message`: an English explanation from the shared dictionary (`sdk/conformance/errors.json`, copied into the package). An unknown key is returned as is.
|
|
118
|
+
- `errors`: for a rejected request body, a map from each field to its first message.
|
|
119
|
+
- `request_id`: the server's `traceId`. Quote it to Qvard support.
|
|
120
|
+
|
|
121
|
+
A request that got no answer (connection error, DNS, timeout) raises `QvardNetworkError`, a subclass of `QvardError` with `status` 0.
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from qvard import QvardError, QvardNetworkError
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
qvard.payments.get(uuid)
|
|
128
|
+
except QvardNetworkError as e:
|
|
129
|
+
print('no answer:', e.friendly_message)
|
|
130
|
+
except QvardError as e:
|
|
131
|
+
print(e.status, e.message, e.friendly_message, e.request_id)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
| `message` | Status | Meaning |
|
|
135
|
+
| --- | --- | --- |
|
|
136
|
+
| `error.wrong.api.key` | 401 | Missing or unknown key |
|
|
137
|
+
| `error.notfound` | 404 | Not in your shop |
|
|
138
|
+
| `error.shop.not.active` | 403 | Shop is not active |
|
|
139
|
+
| `error.origin.not_allowed` | 403 | Origin not in the allowed list (publishable-key flow only) |
|
|
140
|
+
| `error.origin.not_configured` | 403 | No allowed origins configured (publishable-key flow only) |
|
|
141
|
+
| `error.payment.duplicate_id` | 409 | Same id, but a different amount, or the payment is already paid |
|
|
142
|
+
| `Error validation` | 400 | Body rejected; see `errors` |
|
|
143
|
+
| `unsupported_currency` | 422 | Currency not supported |
|
|
144
|
+
| `invalid_amount_precision` | 422 | Too many decimal places for the currency |
|
|
145
|
+
| `rate_unavailable` | 422 | No rate for the currency right now |
|
|
146
|
+
| `ThrottlerException: Too Many Requests` | 429 | Rate limit |
|
|
147
|
+
| `error.internal` | 500 | Failure inside Qvard; quote `request_id` |
|
|
148
|
+
|
|
149
|
+
Payout rejections come back as plain English text, for example `Insufficient balance`, `Method not found` or `Invalid TRON withdrawal address`, with status 400. The dictionary does not list them; `friendly_message` equals `message` for them.
|
|
150
|
+
|
|
151
|
+
## Differences from the Node.js package
|
|
152
|
+
|
|
153
|
+
- Redirects are not followed. A `3xx` answer is raised as a `QvardError`, so the secret key is never sent to another host. (`@qvard-sdk/node` does the same.)
|
|
154
|
+
- `timeout_ms` bounds each socket operation (connecting, each read), not the whole request.
|
|
155
|
+
|
|
156
|
+
## Tests
|
|
157
|
+
|
|
158
|
+
No test dependencies. From `sdk/python`:
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The tests read the shared fixtures from `../conformance`, so they run inside the `sdk/` folder.
|
qvard-0.1.0/README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# qvard (Python)
|
|
2
|
+
|
|
3
|
+
Python client for the Qvard payments API. Zero dependencies (standard library only), Python 3.9 or newer.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install qvard
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import os
|
|
15
|
+
from qvard import Qvard
|
|
16
|
+
|
|
17
|
+
qvard = Qvard(os.environ['QVARD_SECRET_KEY']) # qvard_sk_...
|
|
18
|
+
|
|
19
|
+
payment = qvard.payments.create(id='order-1001', price=10, currency='EUR')
|
|
20
|
+
# send the payer to payment['redirectUrl']
|
|
21
|
+
|
|
22
|
+
info = qvard.payments.get(payment['id'])
|
|
23
|
+
print(info['status']) # wait | partial | completed | rejected
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Options: `Qvard(secret_key, base_url='https://api.qvard.com', timeout_ms=30000)`. `timeout_ms` is in milliseconds.
|
|
27
|
+
|
|
28
|
+
## Keys
|
|
29
|
+
|
|
30
|
+
- Use the secret key `qvard_sk_...` on your server only. Never put it in browser or mobile code. A publishable key (`qvard_pk_...`) is rejected by the constructor because it is for the browser only.
|
|
31
|
+
- Legacy keys without the `qvard_sk_` prefix are rejected by the constructor. Rotate the key in the cabinet (Shop settings → API keys) to get a `qvard_sk_...` key.
|
|
32
|
+
- The SDK sends `Authorization: Bearer <key>`. It never sends `x-api-key`.
|
|
33
|
+
|
|
34
|
+
## Methods
|
|
35
|
+
|
|
36
|
+
| Call | Request | Returns |
|
|
37
|
+
| --- | --- | --- |
|
|
38
|
+
| `payments.create(id=, price=, currency=None, payment_system_id=None)` | `POST /api/v1/payment` | `{id, redirectUrl, telegramUrl?}` |
|
|
39
|
+
| `payments.get(id)` | `GET /api/v1/payment/:id` | `{id, status, createdAt, paidAt, price, currency, paidAmount, token, expiresAt}` |
|
|
40
|
+
| `payouts.create(id=, price=, method_id=, address=, comment=None)` | `POST /api/v1/payout` | `True` |
|
|
41
|
+
| `methods.info(id)` | `GET /api/v1/method/:id/info` | `{server, isReady, isActive}` |
|
|
42
|
+
| `rates.list()` | `GET /api/v1/rates` | `{base, updatedAt, rates}` |
|
|
43
|
+
|
|
44
|
+
The results are the server's `data` as dictionaries, with the server's field names (camelCase).
|
|
45
|
+
|
|
46
|
+
- `id` in `payments.create` is your own order id. The server requires it. The same id with the same amount and currency returns the existing payment. A different amount replaces an unpaid one, and the server answers `409` once money is on the way.
|
|
47
|
+
- `payments.get` answers `error.notfound` for a payment of another shop, the same as for an unknown uuid.
|
|
48
|
+
- `price` is sent as you give it. The server converts it to USD at the current rate and stores that rate with the payment. Unset optional arguments (`None`) are not sent.
|
|
49
|
+
- `convert_for_display(amount, from_, to, rates)` is exported for showing prices only. Pass `qvard.rates.list()['rates']`. The total a payer pays is computed by the server; do not charge the value this function returns. It raises `ValueError` for a currency without a rate.
|
|
50
|
+
|
|
51
|
+
## Payouts
|
|
52
|
+
|
|
53
|
+
`payouts.create` sends money out of your balance. Read this before you call it.
|
|
54
|
+
|
|
55
|
+
- The SDK never retries a payout.
|
|
56
|
+
- The result is **unknown** on a timeout, a network error, a `5xx` answer (including `502`, `503` and `504` from a gateway), or a `2xx` answer whose body is not a JSON object (an array, a string, `null` or an empty body). The error's `friendly_message` says: `Result unknown: check whether the payout went through before retrying (reuse the same id).`. A payout may have been created. In that case the error's `status` is the HTTP status and `message` is the server's key, or `HTTP <status>` when there is none.
|
|
57
|
+
- A `4xx` answer is a refusal: the payout was not created, and `message` is the reason (for example `Insufficient balance`).
|
|
58
|
+
- To retry safely, send the same `id` and the same arguments as the first attempt. See the next section.
|
|
59
|
+
|
|
60
|
+
### Payout idempotency
|
|
61
|
+
|
|
62
|
+
What protects a payout from being paid twice is the `id` field of the request body, which is your payout id.
|
|
63
|
+
|
|
64
|
+
- The server requires `id`: without it the answer is `400`.
|
|
65
|
+
- The server stores `id` on the withdrawal as its idempotency key. Before it creates a withdrawal it looks for an existing withdrawal of your shop with the same method and the same key. If it finds one, it returns that one and creates nothing.
|
|
66
|
+
- The lookup runs under a database lock for your shop and method, so two simultaneous requests with the same `id` cannot both create a payout.
|
|
67
|
+
- Scope: the key is matched within your shop and the `method_id`. The same `id` on a different method creates a second payout. The same `id` in a different shop is independent.
|
|
68
|
+
- A repeated request with the same `id` returns the original payout, and the API answers `True` exactly as it does for a new payout. The response does not say that it was a replay.
|
|
69
|
+
- A repeated request with the same `id` but a different `price`, `address` or `comment` is not compared. The original payout stands and the new values are ignored.
|
|
70
|
+
- The lookup finds only payouts that were created. A request that was rejected with `400` (for example `Insufficient balance`) created nothing, so sending it again with the same `id` is a new attempt.
|
|
71
|
+
- After a key rotation the previous key is not accepted on `POST /api/v1/payout`, even during the 24-hour grace period.
|
|
72
|
+
- v1 has no endpoint to look up a payout by your id. To check whether a payout from a timed-out call exists, look in the cabinet.
|
|
73
|
+
|
|
74
|
+
## Webhooks
|
|
75
|
+
|
|
76
|
+
Qvard calls your callback URL with a JSON body and the header `X-Qvard-Signature: t=<unix>,v1=<hex>[,v1=<hex>]`. The signature is HMAC-SHA256, keyed with your secret key, over `t + "." + body`. Two `v1` values are sent for 24 hours after a key rotation, and either one is valid.
|
|
77
|
+
|
|
78
|
+
Verify the signature over the **raw** body, before any JSON parsing. Read the body as bytes from your framework (not from a parsed form or JSON object):
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
import os
|
|
82
|
+
from qvard import Qvard
|
|
83
|
+
|
|
84
|
+
secret = os.environ['QVARD_SECRET_KEY']
|
|
85
|
+
qvard = Qvard(secret)
|
|
86
|
+
|
|
87
|
+
def handle_webhook(raw_body: bytes, signature_header: str) -> int:
|
|
88
|
+
"""Returns the HTTP status to answer with."""
|
|
89
|
+
try:
|
|
90
|
+
event = qvard.webhooks.construct_event(raw_body, signature_header, secret)
|
|
91
|
+
except ValueError:
|
|
92
|
+
return 400
|
|
93
|
+
# event['id'] is YOUR order id (the one you passed to payments.create), not the payment uuid.
|
|
94
|
+
# Check event['amount'] and event['currency'] against your order before you fulfil it.
|
|
95
|
+
return 200
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- `qvard.webhooks.verify(raw_body, header, secret, tolerance_sec=300)` returns `True` or `False`. It raises `ValueError` only when no secret is given.
|
|
99
|
+
- `qvard.webhooks.construct_event(raw_body, header, secret)` returns the parsed event, or raises `ValueError` when the signature fails.
|
|
100
|
+
- `raw_body` may be `bytes` or `str`. The check accepts a timestamp within `tolerance_sec` (300 seconds) of now, in either direction, and accepts any `v1` that matches. The comparison is constant-time.
|
|
101
|
+
- The event body is `{id, status, paymentLink?, customerEmail?, amount, currency, amountUsd, rate}`. There is no event `type` field.
|
|
102
|
+
- Answer `200`. Qvard treats any other status as a failed delivery and retries it.
|
|
103
|
+
|
|
104
|
+
## Errors
|
|
105
|
+
|
|
106
|
+
Every failed call raises `QvardError`:
|
|
107
|
+
|
|
108
|
+
- `status`: the HTTP status.
|
|
109
|
+
- `message` (also `str(error)`): the server's key, for example `error.wrong.api.key`, or the text it sent.
|
|
110
|
+
- `friendly_message`: an English explanation from the shared dictionary (`sdk/conformance/errors.json`, copied into the package). An unknown key is returned as is.
|
|
111
|
+
- `errors`: for a rejected request body, a map from each field to its first message.
|
|
112
|
+
- `request_id`: the server's `traceId`. Quote it to Qvard support.
|
|
113
|
+
|
|
114
|
+
A request that got no answer (connection error, DNS, timeout) raises `QvardNetworkError`, a subclass of `QvardError` with `status` 0.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from qvard import QvardError, QvardNetworkError
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
qvard.payments.get(uuid)
|
|
121
|
+
except QvardNetworkError as e:
|
|
122
|
+
print('no answer:', e.friendly_message)
|
|
123
|
+
except QvardError as e:
|
|
124
|
+
print(e.status, e.message, e.friendly_message, e.request_id)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
| `message` | Status | Meaning |
|
|
128
|
+
| --- | --- | --- |
|
|
129
|
+
| `error.wrong.api.key` | 401 | Missing or unknown key |
|
|
130
|
+
| `error.notfound` | 404 | Not in your shop |
|
|
131
|
+
| `error.shop.not.active` | 403 | Shop is not active |
|
|
132
|
+
| `error.origin.not_allowed` | 403 | Origin not in the allowed list (publishable-key flow only) |
|
|
133
|
+
| `error.origin.not_configured` | 403 | No allowed origins configured (publishable-key flow only) |
|
|
134
|
+
| `error.payment.duplicate_id` | 409 | Same id, but a different amount, or the payment is already paid |
|
|
135
|
+
| `Error validation` | 400 | Body rejected; see `errors` |
|
|
136
|
+
| `unsupported_currency` | 422 | Currency not supported |
|
|
137
|
+
| `invalid_amount_precision` | 422 | Too many decimal places for the currency |
|
|
138
|
+
| `rate_unavailable` | 422 | No rate for the currency right now |
|
|
139
|
+
| `ThrottlerException: Too Many Requests` | 429 | Rate limit |
|
|
140
|
+
| `error.internal` | 500 | Failure inside Qvard; quote `request_id` |
|
|
141
|
+
|
|
142
|
+
Payout rejections come back as plain English text, for example `Insufficient balance`, `Method not found` or `Invalid TRON withdrawal address`, with status 400. The dictionary does not list them; `friendly_message` equals `message` for them.
|
|
143
|
+
|
|
144
|
+
## Differences from the Node.js package
|
|
145
|
+
|
|
146
|
+
- Redirects are not followed. A `3xx` answer is raised as a `QvardError`, so the secret key is never sent to another host. (`@qvard-sdk/node` does the same.)
|
|
147
|
+
- `timeout_ms` bounds each socket operation (connecting, each read), not the whole request.
|
|
148
|
+
|
|
149
|
+
## Tests
|
|
150
|
+
|
|
151
|
+
No test dependencies. From `sdk/python`:
|
|
152
|
+
|
|
153
|
+
```sh
|
|
154
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The tests read the shared fixtures from `../conformance`, so they run inside the `sdk/` folder.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "qvard"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Qvard payments API client for Python: payments, payouts, rates and webhook signature checks"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
dependencies = []
|
|
12
|
+
|
|
13
|
+
[tool.setuptools.packages.find]
|
|
14
|
+
where = ["src"]
|
qvard-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Qvard payments API client. See README.md."""
|
|
2
|
+
import http.client
|
|
3
|
+
import json
|
|
4
|
+
import socket
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from types import SimpleNamespace
|
|
8
|
+
from typing import Any, Callable, Dict, Optional, Tuple
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
from . import webhooks
|
|
12
|
+
from .errors import PAYOUT_UNKNOWN_RESULT, QvardError, QvardNetworkError, error_from_response, network_error
|
|
13
|
+
|
|
14
|
+
__all__ = ['Qvard', 'QvardError', 'QvardNetworkError', 'convert_for_display', 'webhooks']
|
|
15
|
+
__version__ = '0.1.0'
|
|
16
|
+
|
|
17
|
+
# A 3xx is an error here, not a hop: a redirect could send the Authorization header to another host.
|
|
18
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
19
|
+
def redirect_request(self, *args: Any, **kwargs: Any) -> None:
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_OPENER = urllib.request.build_opener(_NoRedirect)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _urllib_send(method: str, url: str, headers: Dict[str, str], data: Optional[bytes],
|
|
27
|
+
timeout_ms: int) -> Tuple[int, str]:
|
|
28
|
+
# shortcut: timeout_ms bounds each socket operation, not the whole request (Node bounds the whole request),
|
|
29
|
+
# upgrade to a total deadline if a server that trickles bytes becomes a real case
|
|
30
|
+
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
31
|
+
try:
|
|
32
|
+
with _OPENER.open(request, timeout=timeout_ms / 1000) as res:
|
|
33
|
+
return res.status, res.read().decode('utf-8', 'replace')
|
|
34
|
+
except urllib.error.HTTPError as e:
|
|
35
|
+
with e:
|
|
36
|
+
return e.code, e.read().decode('utf-8', 'replace')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_json(text: str) -> Any:
|
|
40
|
+
try:
|
|
41
|
+
return json.loads(text)
|
|
42
|
+
except ValueError:
|
|
43
|
+
return None # a proxy's HTML page, an empty body
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Qvard:
|
|
47
|
+
"""Client for the payments API.
|
|
48
|
+
|
|
49
|
+
`secret_key` is your secret key, `qvard_sk_...`. Never put it in browser code: `qvard_pk_` is the public key.
|
|
50
|
+
Raises ValueError on an empty key, a `qvard_pk_` key, and a key without the `qvard_sk_` prefix.
|
|
51
|
+
`transport` replaces the HTTP layer (tests): called as (method, url, headers, body_bytes_or_None, timeout_ms),
|
|
52
|
+
returns (status, text), raises OSError on a network failure.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, secret_key: str, base_url: str = 'https://api.qvard.com', timeout_ms: int = 30000,
|
|
56
|
+
transport: Optional[Callable[..., Tuple[int, str]]] = None):
|
|
57
|
+
if not secret_key:
|
|
58
|
+
raise ValueError('Qvard: secret key is required')
|
|
59
|
+
if secret_key.startswith('qvard_pk_'):
|
|
60
|
+
raise ValueError('Qvard: publishable key (qvard_pk_) is for the browser only; use the secret key (qvard_sk_) on the server')
|
|
61
|
+
if not secret_key.startswith('qvard_sk_'):
|
|
62
|
+
raise ValueError('Qvard: legacy keys without the qvard_sk_ prefix are not supported; rotate the key in the cabinet')
|
|
63
|
+
|
|
64
|
+
self._secret_key = secret_key
|
|
65
|
+
self._base_url = base_url.rstrip('/')
|
|
66
|
+
self._timeout_ms = timeout_ms
|
|
67
|
+
self._transport = transport or _urllib_send
|
|
68
|
+
|
|
69
|
+
# Only the listed fields are sent: `publishable_key` cannot reach the server from this client.
|
|
70
|
+
self.payments = SimpleNamespace(create=self._payment_create, get=self._payment_get)
|
|
71
|
+
self.payouts = SimpleNamespace(create=self._payout_create)
|
|
72
|
+
self.methods = SimpleNamespace(info=self._method_info)
|
|
73
|
+
self.rates = SimpleNamespace(list=self._rates_list)
|
|
74
|
+
# Webhook signature checks. Needs no network, only the shop secret key.
|
|
75
|
+
self.webhooks = webhooks
|
|
76
|
+
|
|
77
|
+
def _payment_create(self, id: str, price: float, currency: Optional[str] = None,
|
|
78
|
+
payment_system_id: Optional[int] = None) -> Dict[str, Any]:
|
|
79
|
+
"""Create a payment. Returns the uuid and the page to send the payer to."""
|
|
80
|
+
return self._request('POST', '/api/v1/payment', {
|
|
81
|
+
'id': id, 'price': price, 'currency': currency, 'paymentSystemId': payment_system_id,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
def _payment_get(self, id: str) -> Dict[str, Any]:
|
|
85
|
+
"""One payment of your shop, by the uuid that `create` returned. Another shop's payment is `error.notfound`."""
|
|
86
|
+
return self._request('GET', f'/api/v1/payment/{quote(str(id), safe="")}')
|
|
87
|
+
|
|
88
|
+
def _payout_create(self, id: str, price: float, method_id: int, address: str,
|
|
89
|
+
comment: Optional[str] = None) -> Any:
|
|
90
|
+
"""Send money out. Not retried automatically. On a timeout, a network error, a 5xx or an answer that is not JSON
|
|
91
|
+
the result is unknown: the `friendly_message` says so. Repeat only with the same `id` (see README)."""
|
|
92
|
+
return self._request('POST', '/api/v1/payout', {
|
|
93
|
+
'id': id, 'price': price, 'methodId': method_id, 'comment': comment, 'address': address,
|
|
94
|
+
}, payout=True)
|
|
95
|
+
|
|
96
|
+
def _method_info(self, id: int) -> Dict[str, Any]:
|
|
97
|
+
"""Status of a payment method of your shop, by its id from the cabinet."""
|
|
98
|
+
return self._request('GET', f'/api/v1/method/{quote(str(id), safe="")}/info')
|
|
99
|
+
|
|
100
|
+
def _rates_list(self) -> Dict[str, Any]:
|
|
101
|
+
"""Current rates, for display only: the server computes the amount a payment costs."""
|
|
102
|
+
return self._request('GET', '/api/v1/rates')
|
|
103
|
+
|
|
104
|
+
def _request(self, method: str, path: str, body: Optional[Dict[str, Any]] = None, payout: bool = False) -> Any:
|
|
105
|
+
headers = {'Authorization': f'Bearer {self._secret_key}', 'Content-Type': 'application/json'}
|
|
106
|
+
data = None
|
|
107
|
+
if body is not None:
|
|
108
|
+
data = json.dumps({k: v for k, v in body.items() if v is not None}).encode('utf-8')
|
|
109
|
+
try:
|
|
110
|
+
status, text = self._transport(method, self._base_url + path, headers, data, self._timeout_ms)
|
|
111
|
+
except (OSError, http.client.HTTPException) as e:
|
|
112
|
+
raise network_error(e, isinstance(getattr(e, 'reason', e), socket.timeout), payout) from e
|
|
113
|
+
|
|
114
|
+
parsed = _parse_json(text)
|
|
115
|
+
if payout and (status >= 500 or (200 <= status <= 299 and not isinstance(parsed, dict))):
|
|
116
|
+
# The answer is lost or unreadable: the payout may have been created.
|
|
117
|
+
err = error_from_response(status, parsed)
|
|
118
|
+
raise QvardError(status, err.message, err.errors, err.request_id, PAYOUT_UNKNOWN_RESULT)
|
|
119
|
+
if not 200 <= status <= 299:
|
|
120
|
+
raise error_from_response(status, parsed)
|
|
121
|
+
|
|
122
|
+
return parsed.get('data') if isinstance(parsed, dict) else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def convert_for_display(amount: float, from_: str, to: str, rates: Dict[str, float]) -> float:
|
|
126
|
+
"""Display only: converts `amount` between two currencies with the rates from `rates.list()` (units per 1 USD).
|
|
127
|
+
The server computes the amount a payment actually costs; do not charge this number.
|
|
128
|
+
Raises ValueError when a currency has no rate (USD is always 1)."""
|
|
129
|
+
def per_usd(code: str) -> Optional[float]:
|
|
130
|
+
return 1 if code == 'USD' else rates.get(code)
|
|
131
|
+
|
|
132
|
+
from_rate = per_usd(from_)
|
|
133
|
+
to_rate = per_usd(to)
|
|
134
|
+
if not from_rate or not to_rate:
|
|
135
|
+
raise ValueError(f"Qvard: no rate for {to if from_rate else from_}")
|
|
136
|
+
|
|
137
|
+
return (amount / from_rate) * to_rate
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Errors and the friendly-message dictionary. The dictionary is a copy of sdk/conformance/errors.json:
|
|
2
|
+
the package ships without that folder, and tests/test_qvard.py fails if the two drift apart."""
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
ERROR_MESSAGES: Dict[str, str] = {
|
|
6
|
+
'error.wrong.api.key': 'Invalid or missing API key',
|
|
7
|
+
'error.notfound': 'Not found in your shop',
|
|
8
|
+
'error.shop.not.active': 'Shop is not active',
|
|
9
|
+
'error.payment.duplicate_id': 'A payment with this id already exists and cannot be replaced (different amount, or already paid)',
|
|
10
|
+
'error.origin.not_allowed': "Request origin is not in the shop's allowed list",
|
|
11
|
+
'error.origin.not_configured': 'The shop has no allowed origins configured for this key',
|
|
12
|
+
'error.internal': "Internal error on Qvard's side; quote traceId to support",
|
|
13
|
+
'Error validation': 'Request validation failed; see errors for each field',
|
|
14
|
+
'unsupported_currency': 'Currency is not supported',
|
|
15
|
+
'invalid_amount_precision': 'Amount has more decimal places than this currency allows',
|
|
16
|
+
'rate_unavailable': 'No exchange rate is available for this currency right now',
|
|
17
|
+
'ThrottlerException: Too Many Requests': 'Too many requests, retry later',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
# Shown instead of the generic network text for payouts: the money may already have moved.
|
|
21
|
+
PAYOUT_UNKNOWN_RESULT = 'Result unknown: check whether the payout went through before retrying (reuse the same id).'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class QvardError(Exception):
|
|
25
|
+
"""An answer from the server. `message` is the raw key (`error.*`) or text, as the server sent it."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, status: int, message: str, errors: Optional[Any] = None,
|
|
28
|
+
request_id: Optional[str] = None, friendly_message: Optional[str] = None):
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.status = status
|
|
31
|
+
self.message = message
|
|
32
|
+
self.errors = errors
|
|
33
|
+
# The server's `traceId`; quote it to Qvard support.
|
|
34
|
+
self.request_id = request_id
|
|
35
|
+
self.friendly_message = ERROR_MESSAGES.get(message, message) if friendly_message is None else friendly_message
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class QvardNetworkError(QvardError):
|
|
39
|
+
"""No answer arrived (DNS, connection, TLS) or the request timed out. `status` is 0."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, message: str, friendly_message: str):
|
|
42
|
+
super().__init__(0, message, None, None, friendly_message)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def error_from_response(status: int, body: Any) -> QvardError:
|
|
46
|
+
json_body = body if isinstance(body, dict) else {}
|
|
47
|
+
message = json_body.get('message')
|
|
48
|
+
errors = json_body.get('errors')
|
|
49
|
+
request_id = json_body.get('traceId')
|
|
50
|
+
|
|
51
|
+
return QvardError(
|
|
52
|
+
status,
|
|
53
|
+
message if isinstance(message, str) else f'HTTP {status}',
|
|
54
|
+
errors if isinstance(errors, (dict, list)) else None,
|
|
55
|
+
request_id if isinstance(request_id, str) else None,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def network_error(cause: BaseException, timed_out: bool, payout: bool) -> QvardNetworkError:
|
|
60
|
+
if timed_out:
|
|
61
|
+
plain = 'Request timed out'
|
|
62
|
+
message = 'timeout'
|
|
63
|
+
else:
|
|
64
|
+
plain = 'Network error'
|
|
65
|
+
message = f'network: {cause}'
|
|
66
|
+
|
|
67
|
+
return QvardNetworkError(message, PAYOUT_UNKNOWN_RESULT if payout else plain)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Checks `X-Qvard-Signature: t=<unix>,v1=<hex>[,v1=<hex>]`: HMAC-SHA256 with the shop's secret key over `t + "." + rawBody`.
|
|
2
|
+
Pass the RAW body (bytes or str, as received), not re-serialised JSON."""
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Optional, Union
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def verify(raw_body: Union[str, bytes], header: Optional[str], secret: str,
|
|
12
|
+
tolerance_sec: int = 300, now: Optional[int] = None) -> bool:
|
|
13
|
+
"""Returns False for a bad, missing or stale signature; raises ValueError only when no secret is given.
|
|
14
|
+
`tolerance_sec` bounds |now - t| in both directions. Any `v1` may match (two while a key rotation is live).
|
|
15
|
+
`now` (unix seconds) overrides the clock; for tests."""
|
|
16
|
+
if not secret:
|
|
17
|
+
raise ValueError('Qvard: webhook secret is required')
|
|
18
|
+
|
|
19
|
+
t = None
|
|
20
|
+
v1 = []
|
|
21
|
+
for part in (header or '').split(','):
|
|
22
|
+
key, *rest = part.split('=')
|
|
23
|
+
value = '='.join(rest).strip()
|
|
24
|
+
if key.strip() == 't' and t is None:
|
|
25
|
+
t = value
|
|
26
|
+
elif key.strip() == 'v1':
|
|
27
|
+
v1.append(value)
|
|
28
|
+
if not t or not re.fullmatch(r'[0-9]+', t):
|
|
29
|
+
return False
|
|
30
|
+
if abs((int(time.time()) if now is None else now) - int(t)) > tolerance_sec:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
expected = hmac.new(secret.encode('utf-8'), t.encode('ascii') + b'.' + _bytes(raw_body), hashlib.sha256).hexdigest()
|
|
34
|
+
|
|
35
|
+
return any(
|
|
36
|
+
re.fullmatch(r'[0-9a-fA-F]{64}', hex_sig) is not None and hmac.compare_digest(expected, hex_sig.lower())
|
|
37
|
+
for hex_sig in v1
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def construct_event(raw_body: Union[str, bytes], header: Optional[str], secret: str,
|
|
42
|
+
now: Optional[int] = None) -> Any:
|
|
43
|
+
"""Verifies, then parses the raw body. Raises ValueError when the signature does not verify."""
|
|
44
|
+
if not verify(raw_body, header, secret, now=now):
|
|
45
|
+
raise ValueError('Qvard: invalid webhook signature, or timestamp outside the tolerance')
|
|
46
|
+
|
|
47
|
+
return json.loads(_bytes(raw_body).decode('utf-8'))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _bytes(raw_body: Union[str, bytes]) -> bytes:
|
|
51
|
+
return raw_body.encode('utf-8') if isinstance(raw_body, str) else raw_body
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qvard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Qvard payments API client for Python: payments, payouts, rates and webhook signature checks
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
|
|
8
|
+
# qvard (Python)
|
|
9
|
+
|
|
10
|
+
Python client for the Qvard payments API. Zero dependencies (standard library only), Python 3.9 or newer.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
pip install qvard
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import os
|
|
22
|
+
from qvard import Qvard
|
|
23
|
+
|
|
24
|
+
qvard = Qvard(os.environ['QVARD_SECRET_KEY']) # qvard_sk_...
|
|
25
|
+
|
|
26
|
+
payment = qvard.payments.create(id='order-1001', price=10, currency='EUR')
|
|
27
|
+
# send the payer to payment['redirectUrl']
|
|
28
|
+
|
|
29
|
+
info = qvard.payments.get(payment['id'])
|
|
30
|
+
print(info['status']) # wait | partial | completed | rejected
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Options: `Qvard(secret_key, base_url='https://api.qvard.com', timeout_ms=30000)`. `timeout_ms` is in milliseconds.
|
|
34
|
+
|
|
35
|
+
## Keys
|
|
36
|
+
|
|
37
|
+
- Use the secret key `qvard_sk_...` on your server only. Never put it in browser or mobile code. A publishable key (`qvard_pk_...`) is rejected by the constructor because it is for the browser only.
|
|
38
|
+
- Legacy keys without the `qvard_sk_` prefix are rejected by the constructor. Rotate the key in the cabinet (Shop settings → API keys) to get a `qvard_sk_...` key.
|
|
39
|
+
- The SDK sends `Authorization: Bearer <key>`. It never sends `x-api-key`.
|
|
40
|
+
|
|
41
|
+
## Methods
|
|
42
|
+
|
|
43
|
+
| Call | Request | Returns |
|
|
44
|
+
| --- | --- | --- |
|
|
45
|
+
| `payments.create(id=, price=, currency=None, payment_system_id=None)` | `POST /api/v1/payment` | `{id, redirectUrl, telegramUrl?}` |
|
|
46
|
+
| `payments.get(id)` | `GET /api/v1/payment/:id` | `{id, status, createdAt, paidAt, price, currency, paidAmount, token, expiresAt}` |
|
|
47
|
+
| `payouts.create(id=, price=, method_id=, address=, comment=None)` | `POST /api/v1/payout` | `True` |
|
|
48
|
+
| `methods.info(id)` | `GET /api/v1/method/:id/info` | `{server, isReady, isActive}` |
|
|
49
|
+
| `rates.list()` | `GET /api/v1/rates` | `{base, updatedAt, rates}` |
|
|
50
|
+
|
|
51
|
+
The results are the server's `data` as dictionaries, with the server's field names (camelCase).
|
|
52
|
+
|
|
53
|
+
- `id` in `payments.create` is your own order id. The server requires it. The same id with the same amount and currency returns the existing payment. A different amount replaces an unpaid one, and the server answers `409` once money is on the way.
|
|
54
|
+
- `payments.get` answers `error.notfound` for a payment of another shop, the same as for an unknown uuid.
|
|
55
|
+
- `price` is sent as you give it. The server converts it to USD at the current rate and stores that rate with the payment. Unset optional arguments (`None`) are not sent.
|
|
56
|
+
- `convert_for_display(amount, from_, to, rates)` is exported for showing prices only. Pass `qvard.rates.list()['rates']`. The total a payer pays is computed by the server; do not charge the value this function returns. It raises `ValueError` for a currency without a rate.
|
|
57
|
+
|
|
58
|
+
## Payouts
|
|
59
|
+
|
|
60
|
+
`payouts.create` sends money out of your balance. Read this before you call it.
|
|
61
|
+
|
|
62
|
+
- The SDK never retries a payout.
|
|
63
|
+
- The result is **unknown** on a timeout, a network error, a `5xx` answer (including `502`, `503` and `504` from a gateway), or a `2xx` answer whose body is not a JSON object (an array, a string, `null` or an empty body). The error's `friendly_message` says: `Result unknown: check whether the payout went through before retrying (reuse the same id).`. A payout may have been created. In that case the error's `status` is the HTTP status and `message` is the server's key, or `HTTP <status>` when there is none.
|
|
64
|
+
- A `4xx` answer is a refusal: the payout was not created, and `message` is the reason (for example `Insufficient balance`).
|
|
65
|
+
- To retry safely, send the same `id` and the same arguments as the first attempt. See the next section.
|
|
66
|
+
|
|
67
|
+
### Payout idempotency
|
|
68
|
+
|
|
69
|
+
What protects a payout from being paid twice is the `id` field of the request body, which is your payout id.
|
|
70
|
+
|
|
71
|
+
- The server requires `id`: without it the answer is `400`.
|
|
72
|
+
- The server stores `id` on the withdrawal as its idempotency key. Before it creates a withdrawal it looks for an existing withdrawal of your shop with the same method and the same key. If it finds one, it returns that one and creates nothing.
|
|
73
|
+
- The lookup runs under a database lock for your shop and method, so two simultaneous requests with the same `id` cannot both create a payout.
|
|
74
|
+
- Scope: the key is matched within your shop and the `method_id`. The same `id` on a different method creates a second payout. The same `id` in a different shop is independent.
|
|
75
|
+
- A repeated request with the same `id` returns the original payout, and the API answers `True` exactly as it does for a new payout. The response does not say that it was a replay.
|
|
76
|
+
- A repeated request with the same `id` but a different `price`, `address` or `comment` is not compared. The original payout stands and the new values are ignored.
|
|
77
|
+
- The lookup finds only payouts that were created. A request that was rejected with `400` (for example `Insufficient balance`) created nothing, so sending it again with the same `id` is a new attempt.
|
|
78
|
+
- After a key rotation the previous key is not accepted on `POST /api/v1/payout`, even during the 24-hour grace period.
|
|
79
|
+
- v1 has no endpoint to look up a payout by your id. To check whether a payout from a timed-out call exists, look in the cabinet.
|
|
80
|
+
|
|
81
|
+
## Webhooks
|
|
82
|
+
|
|
83
|
+
Qvard calls your callback URL with a JSON body and the header `X-Qvard-Signature: t=<unix>,v1=<hex>[,v1=<hex>]`. The signature is HMAC-SHA256, keyed with your secret key, over `t + "." + body`. Two `v1` values are sent for 24 hours after a key rotation, and either one is valid.
|
|
84
|
+
|
|
85
|
+
Verify the signature over the **raw** body, before any JSON parsing. Read the body as bytes from your framework (not from a parsed form or JSON object):
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import os
|
|
89
|
+
from qvard import Qvard
|
|
90
|
+
|
|
91
|
+
secret = os.environ['QVARD_SECRET_KEY']
|
|
92
|
+
qvard = Qvard(secret)
|
|
93
|
+
|
|
94
|
+
def handle_webhook(raw_body: bytes, signature_header: str) -> int:
|
|
95
|
+
"""Returns the HTTP status to answer with."""
|
|
96
|
+
try:
|
|
97
|
+
event = qvard.webhooks.construct_event(raw_body, signature_header, secret)
|
|
98
|
+
except ValueError:
|
|
99
|
+
return 400
|
|
100
|
+
# event['id'] is YOUR order id (the one you passed to payments.create), not the payment uuid.
|
|
101
|
+
# Check event['amount'] and event['currency'] against your order before you fulfil it.
|
|
102
|
+
return 200
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- `qvard.webhooks.verify(raw_body, header, secret, tolerance_sec=300)` returns `True` or `False`. It raises `ValueError` only when no secret is given.
|
|
106
|
+
- `qvard.webhooks.construct_event(raw_body, header, secret)` returns the parsed event, or raises `ValueError` when the signature fails.
|
|
107
|
+
- `raw_body` may be `bytes` or `str`. The check accepts a timestamp within `tolerance_sec` (300 seconds) of now, in either direction, and accepts any `v1` that matches. The comparison is constant-time.
|
|
108
|
+
- The event body is `{id, status, paymentLink?, customerEmail?, amount, currency, amountUsd, rate}`. There is no event `type` field.
|
|
109
|
+
- Answer `200`. Qvard treats any other status as a failed delivery and retries it.
|
|
110
|
+
|
|
111
|
+
## Errors
|
|
112
|
+
|
|
113
|
+
Every failed call raises `QvardError`:
|
|
114
|
+
|
|
115
|
+
- `status`: the HTTP status.
|
|
116
|
+
- `message` (also `str(error)`): the server's key, for example `error.wrong.api.key`, or the text it sent.
|
|
117
|
+
- `friendly_message`: an English explanation from the shared dictionary (`sdk/conformance/errors.json`, copied into the package). An unknown key is returned as is.
|
|
118
|
+
- `errors`: for a rejected request body, a map from each field to its first message.
|
|
119
|
+
- `request_id`: the server's `traceId`. Quote it to Qvard support.
|
|
120
|
+
|
|
121
|
+
A request that got no answer (connection error, DNS, timeout) raises `QvardNetworkError`, a subclass of `QvardError` with `status` 0.
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from qvard import QvardError, QvardNetworkError
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
qvard.payments.get(uuid)
|
|
128
|
+
except QvardNetworkError as e:
|
|
129
|
+
print('no answer:', e.friendly_message)
|
|
130
|
+
except QvardError as e:
|
|
131
|
+
print(e.status, e.message, e.friendly_message, e.request_id)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
| `message` | Status | Meaning |
|
|
135
|
+
| --- | --- | --- |
|
|
136
|
+
| `error.wrong.api.key` | 401 | Missing or unknown key |
|
|
137
|
+
| `error.notfound` | 404 | Not in your shop |
|
|
138
|
+
| `error.shop.not.active` | 403 | Shop is not active |
|
|
139
|
+
| `error.origin.not_allowed` | 403 | Origin not in the allowed list (publishable-key flow only) |
|
|
140
|
+
| `error.origin.not_configured` | 403 | No allowed origins configured (publishable-key flow only) |
|
|
141
|
+
| `error.payment.duplicate_id` | 409 | Same id, but a different amount, or the payment is already paid |
|
|
142
|
+
| `Error validation` | 400 | Body rejected; see `errors` |
|
|
143
|
+
| `unsupported_currency` | 422 | Currency not supported |
|
|
144
|
+
| `invalid_amount_precision` | 422 | Too many decimal places for the currency |
|
|
145
|
+
| `rate_unavailable` | 422 | No rate for the currency right now |
|
|
146
|
+
| `ThrottlerException: Too Many Requests` | 429 | Rate limit |
|
|
147
|
+
| `error.internal` | 500 | Failure inside Qvard; quote `request_id` |
|
|
148
|
+
|
|
149
|
+
Payout rejections come back as plain English text, for example `Insufficient balance`, `Method not found` or `Invalid TRON withdrawal address`, with status 400. The dictionary does not list them; `friendly_message` equals `message` for them.
|
|
150
|
+
|
|
151
|
+
## Differences from the Node.js package
|
|
152
|
+
|
|
153
|
+
- Redirects are not followed. A `3xx` answer is raised as a `QvardError`, so the secret key is never sent to another host. (`@qvard-sdk/node` does the same.)
|
|
154
|
+
- `timeout_ms` bounds each socket operation (connecting, each read), not the whole request.
|
|
155
|
+
|
|
156
|
+
## Tests
|
|
157
|
+
|
|
158
|
+
No test dependencies. From `sdk/python`:
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The tests read the shared fixtures from `../conformance`, so they run inside the `sdk/` folder.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/qvard/__init__.py
|
|
4
|
+
src/qvard/errors.py
|
|
5
|
+
src/qvard/webhooks.py
|
|
6
|
+
src/qvard.egg-info/PKG-INFO
|
|
7
|
+
src/qvard.egg-info/SOURCES.txt
|
|
8
|
+
src/qvard.egg-info/dependency_links.txt
|
|
9
|
+
src/qvard.egg-info/top_level.txt
|
|
10
|
+
tests/test_qvard.py
|
|
11
|
+
tests/test_webhooks.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
qvard
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Client tests. Run from sdk/python: PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
2
|
+
Fixtures are read from ../conformance (shared with every SDK)."""
|
|
3
|
+
import json
|
|
4
|
+
import socket
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import unittest
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from qvard import Qvard, QvardError, QvardNetworkError, convert_for_display
|
|
12
|
+
from qvard.errors import ERROR_MESSAGES, PAYOUT_UNKNOWN_RESULT
|
|
13
|
+
|
|
14
|
+
ROOT = Path(__file__).resolve().parents[2] # sdk/
|
|
15
|
+
KEY = 'qvard_sk_test123'
|
|
16
|
+
BASE = 'https://api.qvard.com'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fixture(path):
|
|
20
|
+
return json.loads((ROOT / 'conformance' / path).read_text(encoding='utf-8'))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Recorder:
|
|
24
|
+
"""Transport stub: records each call and answers with a fixed status and body (or raises)."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, status=200, body='', error=None):
|
|
27
|
+
self.status = status
|
|
28
|
+
self.body = body if isinstance(body, str) else json.dumps(body)
|
|
29
|
+
self.error = error
|
|
30
|
+
self.calls = []
|
|
31
|
+
|
|
32
|
+
def __call__(self, method, url, headers, data, timeout_ms):
|
|
33
|
+
self.calls.append({'method': method, 'url': url, 'headers': headers, 'data': data, 'timeout_ms': timeout_ms})
|
|
34
|
+
if self.error is not None:
|
|
35
|
+
raise self.error
|
|
36
|
+
return self.status, self.body
|
|
37
|
+
|
|
38
|
+
def sent_json(self):
|
|
39
|
+
return json.loads(self.calls[0]['data'])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ConstructorTests(unittest.TestCase):
|
|
43
|
+
def test_rejects_empty_key(self):
|
|
44
|
+
with self.assertRaisesRegex(ValueError, 'secret key is required'):
|
|
45
|
+
Qvard('')
|
|
46
|
+
|
|
47
|
+
def test_rejects_publishable_key(self):
|
|
48
|
+
with self.assertRaisesRegex(ValueError, 'publishable key \\(qvard_pk_\\) is for the browser only'):
|
|
49
|
+
Qvard('qvard_pk_abc')
|
|
50
|
+
|
|
51
|
+
def test_rejects_legacy_key(self):
|
|
52
|
+
with self.assertRaisesRegex(ValueError, 'legacy keys without the qvard_sk_ prefix are not supported'):
|
|
53
|
+
Qvard('legacy-token-123')
|
|
54
|
+
|
|
55
|
+
def test_accepts_secret_key(self):
|
|
56
|
+
self.assertIsInstance(Qvard(KEY), Qvard)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RequestShapeTests(unittest.TestCase):
|
|
60
|
+
def test_sends_bearer_and_never_x_api_key(self):
|
|
61
|
+
t = Recorder(200, fixture('responses/rates.json'))
|
|
62
|
+
Qvard(KEY, transport=t).rates.list()
|
|
63
|
+
headers = t.calls[0]['headers']
|
|
64
|
+
self.assertEqual(headers['Authorization'], f'Bearer {KEY}')
|
|
65
|
+
self.assertFalse(any(k.lower() == 'x-api-key' for k in headers))
|
|
66
|
+
|
|
67
|
+
def test_base_url_replaces_default_and_loses_trailing_slashes(self):
|
|
68
|
+
t = Recorder(200, fixture('responses/rates.json'))
|
|
69
|
+
Qvard(KEY, base_url='http://localhost:9999/', transport=t).rates.list()
|
|
70
|
+
self.assertEqual(t.calls[0]['url'], 'http://localhost:9999/api/v1/rates')
|
|
71
|
+
|
|
72
|
+
def test_default_timeout_is_30_seconds(self):
|
|
73
|
+
t = Recorder(200, fixture('responses/rates.json'))
|
|
74
|
+
Qvard(KEY, transport=t).rates.list()
|
|
75
|
+
self.assertEqual(t.calls[0]['timeout_ms'], 30000)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class MethodTests(unittest.TestCase):
|
|
79
|
+
def test_payments_create_posts_the_fields_and_unwraps_data(self):
|
|
80
|
+
t = Recorder(201, fixture('responses/payment-create.json'))
|
|
81
|
+
result = Qvard(KEY, transport=t).payments.create(id='order-1', price=10.5, currency='EUR', payment_system_id=3)
|
|
82
|
+
self.assertEqual(t.calls[0]['url'], f'{BASE}/api/v1/payment')
|
|
83
|
+
self.assertEqual(t.calls[0]['method'], 'POST')
|
|
84
|
+
self.assertEqual(t.sent_json(), {'id': 'order-1', 'price': 10.5, 'currency': 'EUR', 'paymentSystemId': 3})
|
|
85
|
+
self.assertEqual(result, fixture('responses/payment-create.json')['data'])
|
|
86
|
+
|
|
87
|
+
def test_payments_create_omits_unset_fields(self):
|
|
88
|
+
t = Recorder(201, fixture('responses/payment-create.json'))
|
|
89
|
+
Qvard(KEY, transport=t).payments.create(id='x', price=0)
|
|
90
|
+
self.assertEqual(t.sent_json(), {'id': 'x', 'price': 0})
|
|
91
|
+
|
|
92
|
+
def test_payments_create_cannot_forward_publishable_key(self):
|
|
93
|
+
t = Recorder(201, fixture('responses/payment-create.json'))
|
|
94
|
+
with self.assertRaises(TypeError):
|
|
95
|
+
Qvard(KEY, transport=t).payments.create(id='x', price=1, publishable_key='qvard_pk_leak')
|
|
96
|
+
self.assertEqual(t.calls, [])
|
|
97
|
+
|
|
98
|
+
def test_payments_get_reads_one_payment_by_uuid_and_unwraps_data(self):
|
|
99
|
+
t = Recorder(200, fixture('responses/payment-get.json'))
|
|
100
|
+
result = Qvard(KEY, transport=t).payments.get('a1b2c3d4-0000-4000-8000-000000000007')
|
|
101
|
+
self.assertEqual(t.calls[0]['url'], f'{BASE}/api/v1/payment/a1b2c3d4-0000-4000-8000-000000000007')
|
|
102
|
+
self.assertEqual(t.calls[0]['method'], 'GET')
|
|
103
|
+
self.assertIsNone(t.calls[0]['data'])
|
|
104
|
+
self.assertEqual(result, fixture('responses/payment-get.json')['data'])
|
|
105
|
+
|
|
106
|
+
def test_payouts_create_posts_the_payout_and_returns_true(self):
|
|
107
|
+
t = Recorder(201, fixture('responses/payout-create.json'))
|
|
108
|
+
result = Qvard(KEY, transport=t).payouts.create(id='payout-1', price=150, method_id=4, address='TXpAy', comment='weekly')
|
|
109
|
+
self.assertEqual(t.calls[0]['url'], f'{BASE}/api/v1/payout')
|
|
110
|
+
self.assertEqual(t.sent_json(), {'id': 'payout-1', 'price': 150, 'methodId': 4, 'address': 'TXpAy', 'comment': 'weekly'})
|
|
111
|
+
self.assertIs(result, True)
|
|
112
|
+
|
|
113
|
+
def test_methods_info_reads_method_and_unwraps_data(self):
|
|
114
|
+
t = Recorder(200, fixture('responses/method-info.json'))
|
|
115
|
+
result = Qvard(KEY, transport=t).methods.info(7)
|
|
116
|
+
self.assertEqual(t.calls[0]['url'], f'{BASE}/api/v1/method/7/info')
|
|
117
|
+
self.assertEqual(result, fixture('responses/method-info.json')['data'])
|
|
118
|
+
|
|
119
|
+
def test_rates_list_reads_rates_and_unwraps_data(self):
|
|
120
|
+
t = Recorder(200, fixture('responses/rates.json'))
|
|
121
|
+
result = Qvard(KEY, transport=t).rates.list()
|
|
122
|
+
self.assertEqual(t.calls[0]['url'], f'{BASE}/api/v1/rates')
|
|
123
|
+
self.assertEqual(result, fixture('responses/rates.json')['data'])
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class ConvertForDisplayTests(unittest.TestCase):
|
|
127
|
+
RATES = {'EUR': 0.92, 'INR': 83.1}
|
|
128
|
+
|
|
129
|
+
def test_converts_through_usd_in_both_directions(self):
|
|
130
|
+
self.assertAlmostEqual(convert_for_display(100, 'EUR', 'USD', self.RATES), 100 / 0.92, places=9)
|
|
131
|
+
self.assertAlmostEqual(convert_for_display(10, 'USD', 'EUR', self.RATES), 9.2, places=9)
|
|
132
|
+
self.assertAlmostEqual(convert_for_display(1, 'EUR', 'INR', self.RATES), 83.1 / 0.92, places=9)
|
|
133
|
+
|
|
134
|
+
def test_raises_for_a_currency_without_a_rate(self):
|
|
135
|
+
with self.assertRaisesRegex(ValueError, 'no rate for GBP'):
|
|
136
|
+
convert_for_display(1, 'GBP', 'USD', self.RATES)
|
|
137
|
+
with self.assertRaisesRegex(ValueError, 'no rate for constructor'):
|
|
138
|
+
convert_for_display(1, 'USD', 'constructor', self.RATES)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ErrorMappingTests(unittest.TestCase):
|
|
142
|
+
def test_each_error_fixture_maps_to_qvard_error(self):
|
|
143
|
+
for case in fixture('error-responses.json'):
|
|
144
|
+
with self.subTest(case=case['name']):
|
|
145
|
+
t = Recorder(case['status'], case['body'])
|
|
146
|
+
with self.assertRaises(QvardError) as ctx:
|
|
147
|
+
Qvard(KEY, transport=t).payments.get('x')
|
|
148
|
+
err = ctx.exception
|
|
149
|
+
self.assertEqual(err.status, case['status'])
|
|
150
|
+
self.assertEqual(err.message, case['expected']['message'])
|
|
151
|
+
self.assertEqual(err.friendly_message, case['expected']['friendlyMessage'])
|
|
152
|
+
self.assertEqual(err.request_id, case['expected'].get('requestId'))
|
|
153
|
+
self.assertEqual(err.errors, case['expected'].get('errors'))
|
|
154
|
+
|
|
155
|
+
def test_non_json_error_body_does_not_crash(self):
|
|
156
|
+
t = Recorder(502, '<html>Bad Gateway</html>')
|
|
157
|
+
with self.assertRaises(QvardError) as ctx:
|
|
158
|
+
Qvard(KEY, transport=t).rates.list()
|
|
159
|
+
self.assertEqual(ctx.exception.status, 502)
|
|
160
|
+
self.assertEqual(ctx.exception.message, 'HTTP 502')
|
|
161
|
+
self.assertIsNone(ctx.exception.request_id)
|
|
162
|
+
|
|
163
|
+
def test_friendly_dictionary_is_the_shared_errors_json(self):
|
|
164
|
+
self.assertEqual(ERROR_MESSAGES, fixture('errors.json'))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class NetworkTests(unittest.TestCase):
|
|
168
|
+
def test_connection_error_becomes_network_error_without_retry(self):
|
|
169
|
+
t = Recorder(error=OSError('connection refused'))
|
|
170
|
+
with self.assertRaises(QvardNetworkError) as ctx:
|
|
171
|
+
Qvard(KEY, transport=t).payments.get('x')
|
|
172
|
+
self.assertEqual(ctx.exception.status, 0)
|
|
173
|
+
self.assertEqual(ctx.exception.friendly_message, 'Network error')
|
|
174
|
+
self.assertEqual(len(t.calls), 1)
|
|
175
|
+
|
|
176
|
+
def test_timeout_becomes_network_error_and_payouts_get_the_unknown_result_warning(self):
|
|
177
|
+
t = Recorder(error=socket.timeout('timed out'))
|
|
178
|
+
client = Qvard(KEY, transport=t)
|
|
179
|
+
with self.assertRaises(QvardNetworkError) as ctx:
|
|
180
|
+
client.payouts.create(id='p', price=1, method_id=1, address='a')
|
|
181
|
+
self.assertEqual(ctx.exception.friendly_message, PAYOUT_UNKNOWN_RESULT)
|
|
182
|
+
self.assertEqual(len(t.calls), 1)
|
|
183
|
+
|
|
184
|
+
with self.assertRaises(QvardNetworkError) as ctx:
|
|
185
|
+
client.payments.create(id='x', price=1)
|
|
186
|
+
self.assertEqual(ctx.exception.friendly_message, 'Request timed out')
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class PayoutUnknownResultTests(unittest.TestCase):
|
|
190
|
+
def test_each_payout_fixture_maps_to_qvard_error(self):
|
|
191
|
+
for case in fixture('payout-unknown.json'):
|
|
192
|
+
with self.subTest(case=case['name']):
|
|
193
|
+
t = Recorder(case['status'], case['body'])
|
|
194
|
+
with self.assertRaises(QvardError) as ctx:
|
|
195
|
+
Qvard(KEY, transport=t).payouts.create(id='p', price=1, method_id=1, address='a')
|
|
196
|
+
err = ctx.exception
|
|
197
|
+
self.assertEqual(err.status, case['status'])
|
|
198
|
+
self.assertEqual(err.message, case['expected']['message'])
|
|
199
|
+
self.assertEqual(err.friendly_message, case['expected']['friendlyMessage'])
|
|
200
|
+
self.assertEqual(err.request_id, case['expected'].get('requestId'))
|
|
201
|
+
|
|
202
|
+
def test_502_on_payments_create_is_a_plain_error(self):
|
|
203
|
+
t = Recorder(502, '<html>Bad Gateway</html>')
|
|
204
|
+
with self.assertRaises(QvardError) as ctx:
|
|
205
|
+
Qvard(KEY, transport=t).payments.create(id='x', price=1)
|
|
206
|
+
self.assertEqual(ctx.exception.friendly_message, 'HTTP 502')
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class LocalServerTests(unittest.TestCase):
|
|
210
|
+
"""Runs the default urllib transport against a real local socket: headers, redirects, timeout."""
|
|
211
|
+
|
|
212
|
+
@classmethod
|
|
213
|
+
def setUpClass(cls):
|
|
214
|
+
hits = []
|
|
215
|
+
|
|
216
|
+
class Handler(BaseHTTPRequestHandler):
|
|
217
|
+
def log_message(self, *args):
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
def _answer(self, status, body, extra=None):
|
|
221
|
+
payload = json.dumps(body).encode('utf-8')
|
|
222
|
+
self.send_response(status)
|
|
223
|
+
for k, v in (extra or {}).items():
|
|
224
|
+
self.send_header(k, v)
|
|
225
|
+
self.send_header('Content-Type', 'application/json')
|
|
226
|
+
self.send_header('Content-Length', str(len(payload)))
|
|
227
|
+
self.end_headers()
|
|
228
|
+
self.wfile.write(payload)
|
|
229
|
+
|
|
230
|
+
def do_GET(self):
|
|
231
|
+
hits.append((self.path, self.headers.get('Authorization')))
|
|
232
|
+
if self.path == '/api/v1/payment/slow':
|
|
233
|
+
time.sleep(1)
|
|
234
|
+
return self._answer(200, {'data': None})
|
|
235
|
+
self._answer(200, fixture('responses/rates.json'))
|
|
236
|
+
|
|
237
|
+
def do_POST(self):
|
|
238
|
+
hits.append((self.path, self.headers.get('Authorization')))
|
|
239
|
+
self.rfile.read(int(self.headers.get('Content-Length', 0)))
|
|
240
|
+
# A redirect must not be followed: the Authorization header would go to the Location host.
|
|
241
|
+
self._answer(302, {}, {'Location': 'http://127.0.0.1:1/elsewhere'})
|
|
242
|
+
|
|
243
|
+
cls.hits = hits
|
|
244
|
+
cls.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
|
245
|
+
cls.server.daemon_threads = True
|
|
246
|
+
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
|
247
|
+
cls.thread.start()
|
|
248
|
+
cls.url = f'http://127.0.0.1:{cls.server.server_address[1]}'
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def tearDownClass(cls):
|
|
252
|
+
cls.server.shutdown()
|
|
253
|
+
cls.server.server_close()
|
|
254
|
+
|
|
255
|
+
def test_real_request_sends_bearer_and_unwraps_data(self):
|
|
256
|
+
self.hits.clear()
|
|
257
|
+
result = Qvard(KEY, base_url=self.url).rates.list()
|
|
258
|
+
self.assertEqual(result, fixture('responses/rates.json')['data'])
|
|
259
|
+
self.assertEqual(self.hits[-1], ('/api/v1/rates', f'Bearer {KEY}'))
|
|
260
|
+
|
|
261
|
+
def test_redirect_is_an_error_and_is_not_followed(self):
|
|
262
|
+
self.hits.clear()
|
|
263
|
+
with self.assertRaises(QvardError) as ctx:
|
|
264
|
+
Qvard(KEY, base_url=self.url).payouts.create(id='p', price=1, method_id=1, address='a')
|
|
265
|
+
self.assertEqual(ctx.exception.status, 302)
|
|
266
|
+
self.assertEqual(len(self.hits), 1)
|
|
267
|
+
|
|
268
|
+
def test_slow_server_times_out(self):
|
|
269
|
+
with self.assertRaises(QvardNetworkError) as ctx:
|
|
270
|
+
Qvard(KEY, base_url=self.url, timeout_ms=100).payments.get('slow')
|
|
271
|
+
self.assertEqual(ctx.exception.friendly_message, 'Request timed out')
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if __name__ == '__main__':
|
|
275
|
+
unittest.main()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Webhook signature tests over the shared vectors in ../conformance/webhook-vectors.json."""
|
|
2
|
+
import json
|
|
3
|
+
import unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from qvard import Qvard, webhooks
|
|
7
|
+
|
|
8
|
+
ROOT = Path(__file__).resolve().parents[2] # sdk/
|
|
9
|
+
VECTORS = json.loads((ROOT / 'conformance' / 'webhook-vectors.json').read_text(encoding='utf-8'))
|
|
10
|
+
KEY = 'qvard_sk_test123'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WebhookTests(unittest.TestCase):
|
|
14
|
+
def test_every_vector(self):
|
|
15
|
+
for case in VECTORS['cases']:
|
|
16
|
+
with self.subTest(case=case['name']):
|
|
17
|
+
body = case.get('rawBody', VECTORS['rawBody'])
|
|
18
|
+
self.assertIs(
|
|
19
|
+
webhooks.verify(body, case['header'], VECTORS['secret'], now=VECTORS['now']),
|
|
20
|
+
case['expected'],
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def test_uppercase_hex_of_a_valid_signature_is_accepted(self):
|
|
24
|
+
valid = next(c for c in VECTORS['cases'] if c['expected'] is True and 'rawBody' not in c)
|
|
25
|
+
t, v1 = valid['header'].split(',', 1)
|
|
26
|
+
self.assertTrue(webhooks.verify(VECTORS['rawBody'], f"{t},{v1[:3]}{v1[3:].upper()}", VECTORS['secret'], now=VECTORS['now']))
|
|
27
|
+
|
|
28
|
+
def test_construct_event_returns_parsed_event_for_valid_signature(self):
|
|
29
|
+
valid = next(c for c in VECTORS['cases'] if c['expected'] is True and 'rawBody' not in c)
|
|
30
|
+
event = webhooks.construct_event(VECTORS['rawBody'], valid['header'], VECTORS['secret'], now=VECTORS['now'])
|
|
31
|
+
self.assertEqual(event, json.loads(VECTORS['rawBody']))
|
|
32
|
+
self.assertEqual(event['id'], 'ext-42')
|
|
33
|
+
|
|
34
|
+
def test_construct_event_raises_for_bad_signature(self):
|
|
35
|
+
bad = next(c for c in VECTORS['cases'] if c['expected'] is False)
|
|
36
|
+
with self.assertRaisesRegex(ValueError, 'invalid webhook signature'):
|
|
37
|
+
webhooks.construct_event(VECTORS['rawBody'], bad['header'], VECTORS['secret'], now=VECTORS['now'])
|
|
38
|
+
|
|
39
|
+
def test_bytes_body_and_wider_tolerance(self):
|
|
40
|
+
old = next(c for c in VECTORS['cases'] if c['name'].startswith('t 301 seconds old'))
|
|
41
|
+
raw = VECTORS['rawBody'].encode('utf-8')
|
|
42
|
+
self.assertFalse(webhooks.verify(raw, old['header'], VECTORS['secret'], now=VECTORS['now']))
|
|
43
|
+
self.assertTrue(webhooks.verify(raw, old['header'], VECTORS['secret'], tolerance_sec=600, now=VECTORS['now']))
|
|
44
|
+
|
|
45
|
+
def test_verify_raises_when_no_secret_is_given(self):
|
|
46
|
+
with self.assertRaisesRegex(ValueError, 'webhook secret is required'):
|
|
47
|
+
webhooks.verify(VECTORS['rawBody'], 't=1,v1=00', '')
|
|
48
|
+
|
|
49
|
+
def test_client_exposes_the_same_functions(self):
|
|
50
|
+
self.assertIs(Qvard(KEY).webhooks.verify, webhooks.verify)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == '__main__':
|
|
54
|
+
unittest.main()
|