afropin 1.0.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.
- afropin-1.0.0/LICENSE +21 -0
- afropin-1.0.0/PKG-INFO +112 -0
- afropin-1.0.0/README.md +93 -0
- afropin-1.0.0/afropin/__init__.py +8 -0
- afropin-1.0.0/afropin/client.py +90 -0
- afropin-1.0.0/afropin/errors.py +13 -0
- afropin-1.0.0/afropin/page.py +19 -0
- afropin-1.0.0/afropin/resources.py +329 -0
- afropin-1.0.0/afropin/webhooks.py +29 -0
- afropin-1.0.0/afropin.egg-info/PKG-INFO +112 -0
- afropin-1.0.0/afropin.egg-info/SOURCES.txt +14 -0
- afropin-1.0.0/afropin.egg-info/dependency_links.txt +1 -0
- afropin-1.0.0/afropin.egg-info/top_level.txt +1 -0
- afropin-1.0.0/pyproject.toml +28 -0
- afropin-1.0.0/setup.cfg +4 -0
- afropin-1.0.0/tests/test_sdk.py +70 -0
afropin-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AfroPin Software Limited
|
|
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.
|
afropin-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: afropin
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python library for the AfroPin gift card API: sell, top up and redeem PINs, quotes, stores and webhooks
|
|
5
|
+
Author: AfroPin Software Limited
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://afropin.com/developers
|
|
8
|
+
Project-URL: Documentation, https://afropin.com/user/docs/introduction
|
|
9
|
+
Project-URL: Source, https://github.com/afropin/afropin-python
|
|
10
|
+
Keywords: afropin,gift cards,vouchers,pins,api
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# AfroPin for Python
|
|
21
|
+
|
|
22
|
+
The AfroPin gift card API from Python: sell, top up and redeem PINs, quotes, stores and webhooks.
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
pip install afropin
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Python 3.8 or newer. No dependencies.
|
|
29
|
+
|
|
30
|
+
## Start
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import os
|
|
34
|
+
from afropin import Client
|
|
35
|
+
|
|
36
|
+
afropin = Client(os.environ["AFROPIN_KEY_ID"], os.environ["AFROPIN_SECRET"])
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Your API key's mode decides whether you work with test or live records; the address is the same for both. Keep the
|
|
40
|
+
secret on your server, never in a browser or app.
|
|
41
|
+
|
|
42
|
+
### Reseller: sell a PIN
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
pin = afropin.reseller.pins.sell({
|
|
46
|
+
"catalog_id": catalog_id, # from afropin.reseller.catalog.list()
|
|
47
|
+
"amount": 5000,
|
|
48
|
+
"external_reference": order_id, # your own reference; reusing it fails safely
|
|
49
|
+
"first_name": "Ada",
|
|
50
|
+
"last_name": "Obi",
|
|
51
|
+
"email": "ada@example.com",
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
pin["pin_code"] # the full code: returned only here, so deliver it to your customer now
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Brand: check and redeem a PIN
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
check = afropin.brand.pins.check({"pin_code": code, "amount": 2500})
|
|
61
|
+
if check["can_redeem"]:
|
|
62
|
+
afropin.brand.pins.redeem({"pin_code": code, "amount": 2500, "external_reference": sale_id})
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Methods use snake_case (`top_up`, `new_code`, `wallet_links`); every call is listed in [METHODS.md](../METHODS.md).
|
|
66
|
+
Parameters and response fields are the API's own, as in the developer docs.
|
|
67
|
+
|
|
68
|
+
## Errors
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from afropin import AfroPinError
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
afropin.reseller.pins.top_up({"pin_id": pin_id, "amount": 1000, "external_reference": ref})
|
|
75
|
+
except AfroPinError as error:
|
|
76
|
+
error.code # for example pin_not_found, amount_too_large, duplicate_external_reference
|
|
77
|
+
error.fields # per-field messages for input problems
|
|
78
|
+
error.status # HTTP status, or 0 when the API wasn't reached (connection_failed)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Branch on `code`; the message is for people and may change.
|
|
82
|
+
|
|
83
|
+
## Lists
|
|
84
|
+
|
|
85
|
+
Lists return a `Page`: iterate it, and read `paging` for `page`, `pages` and `total`.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
page = afropin.brand.payments.list({"limit": 50})
|
|
89
|
+
for payment in page:
|
|
90
|
+
...
|
|
91
|
+
if page.has_more:
|
|
92
|
+
following = afropin.brand.payments.list({"limit": 50, "page": page.paging["page"] + 1})
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Idempotency and retries
|
|
96
|
+
|
|
97
|
+
Each request gets a new `Idempotency-Key`. To use your own: `sell(params, idempotency_key=key)`. Nothing is retried for
|
|
98
|
+
you. If a sale times out, send it again with the same `external_reference`: it either goes through once, or fails with
|
|
99
|
+
`duplicate_external_reference`.
|
|
100
|
+
|
|
101
|
+
## Webhooks
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from afropin import webhooks
|
|
105
|
+
|
|
106
|
+
# Flask
|
|
107
|
+
event = webhooks.parse(request.get_data(), request.headers.get("webhook-secret"), WEBHOOK_SECRET)
|
|
108
|
+
if event["type"] == "pin.spent":
|
|
109
|
+
...
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`parse` raises `invalid_signature` when the signature doesn't match. Use the raw body exactly as received.
|
afropin-1.0.0/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# AfroPin for Python
|
|
2
|
+
|
|
3
|
+
The AfroPin gift card API from Python: sell, top up and redeem PINs, quotes, stores and webhooks.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pip install afropin
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Python 3.8 or newer. No dependencies.
|
|
10
|
+
|
|
11
|
+
## Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import os
|
|
15
|
+
from afropin import Client
|
|
16
|
+
|
|
17
|
+
afropin = Client(os.environ["AFROPIN_KEY_ID"], os.environ["AFROPIN_SECRET"])
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Your API key's mode decides whether you work with test or live records; the address is the same for both. Keep the
|
|
21
|
+
secret on your server, never in a browser or app.
|
|
22
|
+
|
|
23
|
+
### Reseller: sell a PIN
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
pin = afropin.reseller.pins.sell({
|
|
27
|
+
"catalog_id": catalog_id, # from afropin.reseller.catalog.list()
|
|
28
|
+
"amount": 5000,
|
|
29
|
+
"external_reference": order_id, # your own reference; reusing it fails safely
|
|
30
|
+
"first_name": "Ada",
|
|
31
|
+
"last_name": "Obi",
|
|
32
|
+
"email": "ada@example.com",
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
pin["pin_code"] # the full code: returned only here, so deliver it to your customer now
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Brand: check and redeem a PIN
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
check = afropin.brand.pins.check({"pin_code": code, "amount": 2500})
|
|
42
|
+
if check["can_redeem"]:
|
|
43
|
+
afropin.brand.pins.redeem({"pin_code": code, "amount": 2500, "external_reference": sale_id})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Methods use snake_case (`top_up`, `new_code`, `wallet_links`); every call is listed in [METHODS.md](../METHODS.md).
|
|
47
|
+
Parameters and response fields are the API's own, as in the developer docs.
|
|
48
|
+
|
|
49
|
+
## Errors
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from afropin import AfroPinError
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
afropin.reseller.pins.top_up({"pin_id": pin_id, "amount": 1000, "external_reference": ref})
|
|
56
|
+
except AfroPinError as error:
|
|
57
|
+
error.code # for example pin_not_found, amount_too_large, duplicate_external_reference
|
|
58
|
+
error.fields # per-field messages for input problems
|
|
59
|
+
error.status # HTTP status, or 0 when the API wasn't reached (connection_failed)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Branch on `code`; the message is for people and may change.
|
|
63
|
+
|
|
64
|
+
## Lists
|
|
65
|
+
|
|
66
|
+
Lists return a `Page`: iterate it, and read `paging` for `page`, `pages` and `total`.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
page = afropin.brand.payments.list({"limit": 50})
|
|
70
|
+
for payment in page:
|
|
71
|
+
...
|
|
72
|
+
if page.has_more:
|
|
73
|
+
following = afropin.brand.payments.list({"limit": 50, "page": page.paging["page"] + 1})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Idempotency and retries
|
|
77
|
+
|
|
78
|
+
Each request gets a new `Idempotency-Key`. To use your own: `sell(params, idempotency_key=key)`. Nothing is retried for
|
|
79
|
+
you. If a sale times out, send it again with the same `external_reference`: it either goes through once, or fails with
|
|
80
|
+
`duplicate_external_reference`.
|
|
81
|
+
|
|
82
|
+
## Webhooks
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from afropin import webhooks
|
|
86
|
+
|
|
87
|
+
# Flask
|
|
88
|
+
event = webhooks.parse(request.get_data(), request.headers.get("webhook-secret"), WEBHOOK_SECRET)
|
|
89
|
+
if event["type"] == "pin.spent":
|
|
90
|
+
...
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`parse` raises `invalid_signature` when the signature doesn't match. Use the raw body exactly as received.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""The AfroPin gift card API. See https://afropin.com/user/docs for every endpoint and field."""
|
|
2
|
+
|
|
3
|
+
from .client import BASE_URL, VERSION, Client
|
|
4
|
+
from .errors import AfroPinError
|
|
5
|
+
from .page import Page
|
|
6
|
+
from . import webhooks
|
|
7
|
+
|
|
8
|
+
__all__ = ["Client", "AfroPinError", "Page", "webhooks", "VERSION", "BASE_URL"]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""The AfroPin API client: request signing, idempotency keys, responses and errors."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
|
|
12
|
+
from .errors import AfroPinError
|
|
13
|
+
from .page import Page
|
|
14
|
+
|
|
15
|
+
VERSION = "1.0.0"
|
|
16
|
+
BASE_URL = "https://afropin.com/api/v1"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Client:
|
|
20
|
+
"""
|
|
21
|
+
Signs every request with your key (HMAC-SHA256 over "METHOD.path.timestamp.nonce.sha256(body)"), sends a fresh
|
|
22
|
+
Idempotency-Key with each one, and returns the response's data. Your key's mode (test or live) decides which records
|
|
23
|
+
you work with.
|
|
24
|
+
|
|
25
|
+
afropin = Client("key_id", "secret")
|
|
26
|
+
pin = afropin.reseller.pins.sell({"catalog_id": "...", "amount": 5000, ...})
|
|
27
|
+
|
|
28
|
+
Requests are never retried for you: a retried sale with the same external_reference fails safely with
|
|
29
|
+
duplicate_external_reference, so retry yourself with a new idempotency key when you need to.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, key_id, secret, base_url=BASE_URL, timeout=30, transport=None):
|
|
33
|
+
from .resources import Brand, Reseller
|
|
34
|
+
|
|
35
|
+
self._key_id = key_id
|
|
36
|
+
self._secret = secret
|
|
37
|
+
self._base_url = base_url.rstrip("/")
|
|
38
|
+
self._timeout = timeout
|
|
39
|
+
# transport(method, url, headers, body) -> (status, raw body): for tests
|
|
40
|
+
self._transport = transport
|
|
41
|
+
self.reseller = Reseller(self)
|
|
42
|
+
self.brand = Brand(self)
|
|
43
|
+
|
|
44
|
+
def request(self, method, path, params=None, idempotency_key=None, list_=False):
|
|
45
|
+
"""Send one request: its data, or a Page for lists. params are the query (GET, DELETE) or the JSON body."""
|
|
46
|
+
params = params or {}
|
|
47
|
+
url = "%s/%s" % (self._base_url, path.lstrip("/"))
|
|
48
|
+
body = ""
|
|
49
|
+
if method in ("GET", "DELETE"):
|
|
50
|
+
query = {key: value for key, value in params.items() if value is not None}
|
|
51
|
+
if query:
|
|
52
|
+
url += "?" + urllib.parse.urlencode(query)
|
|
53
|
+
else:
|
|
54
|
+
body = json.dumps(params, separators=(",", ":"), ensure_ascii=False)
|
|
55
|
+
|
|
56
|
+
timestamp = str(int(time.time()))
|
|
57
|
+
nonce = str(uuid.uuid4())
|
|
58
|
+
signed_path = urllib.parse.urlparse(url).path.lstrip("/")
|
|
59
|
+
canonical = ".".join([method, signed_path, timestamp, nonce, hashlib.sha256(body.encode()).hexdigest()])
|
|
60
|
+
headers = {
|
|
61
|
+
"X-Key-Id": self._key_id,
|
|
62
|
+
"X-Timestamp": timestamp,
|
|
63
|
+
"X-Nonce": nonce,
|
|
64
|
+
"X-Signature": hmac.new(self._secret.encode(), canonical.encode(), hashlib.sha256).hexdigest(),
|
|
65
|
+
"Idempotency-Key": idempotency_key or str(uuid.uuid4()),
|
|
66
|
+
"Accept": "application/json",
|
|
67
|
+
"Content-Type": "application/json",
|
|
68
|
+
"User-Agent": "afropin-python/%s" % VERSION,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
status, raw = (self._transport or self._send)(method, url, headers, body)
|
|
72
|
+
try:
|
|
73
|
+
payload = json.loads(raw)
|
|
74
|
+
except ValueError:
|
|
75
|
+
raise AfroPinError("invalid_response", "The API sent a response that is not JSON", status=status)
|
|
76
|
+
if not payload.get("ok"):
|
|
77
|
+
error = payload.get("error") or {}
|
|
78
|
+
raise AfroPinError(error.get("code", "unknown_error"), error.get("message", "The request failed"), error.get("fields"), status)
|
|
79
|
+
|
|
80
|
+
return Page(payload.get("data") or [], payload.get("paging")) if list_ else payload.get("data")
|
|
81
|
+
|
|
82
|
+
def _send(self, method, url, headers, body):
|
|
83
|
+
request = urllib.request.Request(url, data=body.encode() if body else None, headers=headers, method=method)
|
|
84
|
+
try:
|
|
85
|
+
with urllib.request.urlopen(request, timeout=self._timeout) as response:
|
|
86
|
+
return response.status, response.read().decode()
|
|
87
|
+
except urllib.error.HTTPError as error:
|
|
88
|
+
return error.code, error.read().decode()
|
|
89
|
+
except (urllib.error.URLError, OSError) as error:
|
|
90
|
+
raise AfroPinError("connection_failed", "Could not reach the AfroPin API: %s" % error)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class AfroPinError(Exception):
|
|
2
|
+
"""
|
|
3
|
+
A request the API refused, or one that couldn't be made. Branch on code (for example pin_balance_low or
|
|
4
|
+
duplicate_external_reference); the message is for people and may change. fields has per-field messages for input
|
|
5
|
+
problems. status is the HTTP status, or 0 when the API wasn't reached.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
def __init__(self, code, message, fields=None, status=0):
|
|
9
|
+
super().__init__(message)
|
|
10
|
+
self.code = code
|
|
11
|
+
self.message = message
|
|
12
|
+
self.fields = fields
|
|
13
|
+
self.status = status
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class Page:
|
|
2
|
+
"""
|
|
3
|
+
One page of a list. Iterate it for the records; paging is None when you asked for page="all". Ask for the next
|
|
4
|
+
page with {"page": page.paging["page"] + 1}.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
def __init__(self, data, paging=None):
|
|
8
|
+
self.data = data
|
|
9
|
+
self.paging = paging
|
|
10
|
+
|
|
11
|
+
def __iter__(self):
|
|
12
|
+
return iter(self.data)
|
|
13
|
+
|
|
14
|
+
def __len__(self):
|
|
15
|
+
return len(self.data)
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def has_more(self):
|
|
19
|
+
return self.paging is not None and self.paging["page"] < self.paging["pages"]
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# Generated from sdks/spec/endpoints.json by sdks/spec/generate.py. Don't edit by hand: change the spec and run it again.
|
|
2
|
+
|
|
3
|
+
"""Every AfroPin API call, by account type and area."""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ResellerWallets:
|
|
7
|
+
def __init__(self, client):
|
|
8
|
+
self._client = client
|
|
9
|
+
|
|
10
|
+
def list(self, params=None, idempotency_key=None):
|
|
11
|
+
"""Your wallets and their balances, one per currency. (GET /api/v1/reseller/wallets)"""
|
|
12
|
+
return self._client.request("GET", "reseller/wallets", params, idempotency_key, list_=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ResellerStores:
|
|
16
|
+
def __init__(self, client):
|
|
17
|
+
self._client = client
|
|
18
|
+
|
|
19
|
+
def list(self, params=None, idempotency_key=None):
|
|
20
|
+
"""Your stores. (GET /api/v1/reseller/stores)"""
|
|
21
|
+
return self._client.request("GET", "reseller/stores", params, idempotency_key, list_=True)
|
|
22
|
+
|
|
23
|
+
def create(self, params=None, idempotency_key=None):
|
|
24
|
+
"""Add a store. (POST /api/v1/reseller/stores)"""
|
|
25
|
+
return self._client.request("POST", "reseller/stores", params, idempotency_key)
|
|
26
|
+
|
|
27
|
+
def update(self, store_id, params=None, idempotency_key=None):
|
|
28
|
+
"""Edit a store. (POST /api/v1/reseller/stores/{store_id})"""
|
|
29
|
+
return self._client.request("POST", "reseller/stores/%s" % (_quote(store_id),), params, idempotency_key)
|
|
30
|
+
|
|
31
|
+
def delete(self, store_id, params=None, idempotency_key=None):
|
|
32
|
+
"""Remove a store. (DELETE /api/v1/reseller/stores/{store_id})"""
|
|
33
|
+
return self._client.request("DELETE", "reseller/stores/%s" % (_quote(store_id),), params, idempotency_key)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ResellerCards:
|
|
37
|
+
def __init__(self, client):
|
|
38
|
+
self._client = client
|
|
39
|
+
|
|
40
|
+
def lookup(self, params=None, idempotency_key=None):
|
|
41
|
+
"""Look up one of your printed cards before activating it. (POST /api/v1/reseller/cards/lookup)"""
|
|
42
|
+
return self._client.request("POST", "reseller/cards/lookup", params, idempotency_key)
|
|
43
|
+
|
|
44
|
+
def activate(self, params=None, idempotency_key=None):
|
|
45
|
+
"""Activate a printed card: sells a PIN for the amount loaded. (POST /api/v1/reseller/cards/activate)"""
|
|
46
|
+
return self._client.request("POST", "reseller/cards/activate", params, idempotency_key)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ResellerCatalog:
|
|
50
|
+
def __init__(self, client):
|
|
51
|
+
self._client = client
|
|
52
|
+
|
|
53
|
+
def list(self, params=None, idempotency_key=None):
|
|
54
|
+
"""The products you can sell. (GET /api/v1/reseller/catalog)"""
|
|
55
|
+
return self._client.request("GET", "reseller/catalog", params, idempotency_key, list_=True)
|
|
56
|
+
|
|
57
|
+
def get(self, catalog_id, params=None, idempotency_key=None):
|
|
58
|
+
"""One product you can sell. (GET /api/v1/reseller/catalog/{catalog_id})"""
|
|
59
|
+
return self._client.request("GET", "reseller/catalog/%s" % (_quote(catalog_id),), params, idempotency_key)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ResellerPins:
|
|
63
|
+
def __init__(self, client):
|
|
64
|
+
self._client = client
|
|
65
|
+
|
|
66
|
+
def sell(self, params=None, idempotency_key=None):
|
|
67
|
+
"""Sell a PIN. The response has its full code (pin_code), returned only here. (POST /api/v1/reseller/pins)"""
|
|
68
|
+
return self._client.request("POST", "reseller/pins", params, idempotency_key)
|
|
69
|
+
|
|
70
|
+
def get(self, pin_id, params=None, idempotency_key=None):
|
|
71
|
+
"""One of your PINs: balance, status, masks, customer, family cards. (GET /api/v1/reseller/pins/{pin_id})"""
|
|
72
|
+
return self._client.request("GET", "reseller/pins/%s" % (_quote(pin_id),), params, idempotency_key)
|
|
73
|
+
|
|
74
|
+
def movements(self, pin_id, params=None, idempotency_key=None):
|
|
75
|
+
"""A PIN's movements, newest first. (GET /api/v1/reseller/pins/{pin_id}/movements)"""
|
|
76
|
+
return self._client.request("GET", "reseller/pins/%s/movements" % (_quote(pin_id),), params, idempotency_key, list_=True)
|
|
77
|
+
|
|
78
|
+
def top_up(self, params=None, idempotency_key=None):
|
|
79
|
+
"""Add value to a PIN. (POST /api/v1/reseller/pins/top-up)"""
|
|
80
|
+
return self._client.request("POST", "reseller/pins/top-up", params, idempotency_key)
|
|
81
|
+
|
|
82
|
+
def block(self, params=None, idempotency_key=None):
|
|
83
|
+
"""Block (freeze) a PIN. Its cardholder can't undo it. (POST /api/v1/reseller/pins/block)"""
|
|
84
|
+
return self._client.request("POST", "reseller/pins/block", params, idempotency_key)
|
|
85
|
+
|
|
86
|
+
def unblock(self, params=None, idempotency_key=None):
|
|
87
|
+
"""Unblock a PIN. (POST /api/v1/reseller/pins/unblock)"""
|
|
88
|
+
return self._client.request("POST", "reseller/pins/unblock", params, idempotency_key)
|
|
89
|
+
|
|
90
|
+
def new_code(self, params=None, idempotency_key=None):
|
|
91
|
+
"""Give a PIN a new code; the old one stops working. (POST /api/v1/reseller/pins/new-code)"""
|
|
92
|
+
return self._client.request("POST", "reseller/pins/new-code", params, idempotency_key)
|
|
93
|
+
|
|
94
|
+
def wallet_links(self, params=None, idempotency_key=None):
|
|
95
|
+
"""Links that add a PIN to Google Wallet or Apple Wallet. (POST /api/v1/reseller/pins/wallet-links)"""
|
|
96
|
+
return self._client.request("POST", "reseller/pins/wallet-links", params, idempotency_key)
|
|
97
|
+
|
|
98
|
+
def set_webhook(self, params=None, idempotency_key=None):
|
|
99
|
+
"""Change a PIN's webhook. (POST /api/v1/reseller/pins/webhook)"""
|
|
100
|
+
return self._client.request("POST", "reseller/pins/webhook", params, idempotency_key)
|
|
101
|
+
|
|
102
|
+
def cashouts(self, pin_id, params=None, idempotency_key=None):
|
|
103
|
+
"""A PIN's cashouts. (GET /api/v1/reseller/pins/{pin_id}/cashouts)"""
|
|
104
|
+
return self._client.request("GET", "reseller/pins/%s/cashouts" % (_quote(pin_id),), params, idempotency_key, list_=True)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ResellerBrandStores:
|
|
108
|
+
def __init__(self, client):
|
|
109
|
+
self._client = client
|
|
110
|
+
|
|
111
|
+
def list(self, params=None, idempotency_key=None):
|
|
112
|
+
"""Brand stores where your customers can spend the PINs you sell. (GET /api/v1/reseller/brand-stores)"""
|
|
113
|
+
return self._client.request("GET", "reseller/brand-stores", params, idempotency_key, list_=True)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ResellerCashouts:
|
|
117
|
+
def __init__(self, client):
|
|
118
|
+
self._client = client
|
|
119
|
+
|
|
120
|
+
def request(self, params=None, idempotency_key=None):
|
|
121
|
+
"""Request a cashout (only while cashouts are switched on). (POST /api/v1/reseller/cashouts)"""
|
|
122
|
+
return self._client.request("POST", "reseller/cashouts", params, idempotency_key)
|
|
123
|
+
|
|
124
|
+
def send_code(self, params=None, idempotency_key=None):
|
|
125
|
+
"""Send the customer the confirmation code again. (POST /api/v1/reseller/cashouts/send-code)"""
|
|
126
|
+
return self._client.request("POST", "reseller/cashouts/send-code", params, idempotency_key)
|
|
127
|
+
|
|
128
|
+
def confirm(self, cashout_id, params=None, idempotency_key=None):
|
|
129
|
+
"""Confirm a cashout with the customer's code. (POST /api/v1/reseller/cashouts/{cashout_id}/confirm)"""
|
|
130
|
+
return self._client.request("POST", "reseller/cashouts/%s/confirm" % (_quote(cashout_id),), params, idempotency_key)
|
|
131
|
+
|
|
132
|
+
def void(self, cashout_id, params=None, idempotency_key=None):
|
|
133
|
+
"""Void an unpaid cashout. (POST /api/v1/reseller/cashouts/{cashout_id}/void)"""
|
|
134
|
+
return self._client.request("POST", "reseller/cashouts/%s/void" % (_quote(cashout_id),), params, idempotency_key)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Reseller:
|
|
138
|
+
"""The reseller API (/api/v1/reseller): every call a reseller account can make, by area."""
|
|
139
|
+
|
|
140
|
+
def __init__(self, client):
|
|
141
|
+
self.wallets = ResellerWallets(client)
|
|
142
|
+
self.stores = ResellerStores(client)
|
|
143
|
+
self.cards = ResellerCards(client)
|
|
144
|
+
self.catalog = ResellerCatalog(client)
|
|
145
|
+
self.pins = ResellerPins(client)
|
|
146
|
+
self.brand_stores = ResellerBrandStores(client)
|
|
147
|
+
self.cashouts = ResellerCashouts(client)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class BrandWallets:
|
|
151
|
+
def __init__(self, client):
|
|
152
|
+
self._client = client
|
|
153
|
+
|
|
154
|
+
def list(self, params=None, idempotency_key=None):
|
|
155
|
+
"""Your wallets and their balances, one per currency. (GET /api/v1/brand/wallets)"""
|
|
156
|
+
return self._client.request("GET", "brand/wallets", params, idempotency_key, list_=True)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class BrandStores:
|
|
160
|
+
def __init__(self, client):
|
|
161
|
+
self._client = client
|
|
162
|
+
|
|
163
|
+
def list(self, params=None, idempotency_key=None):
|
|
164
|
+
"""Your stores. (GET /api/v1/brand/stores)"""
|
|
165
|
+
return self._client.request("GET", "brand/stores", params, idempotency_key, list_=True)
|
|
166
|
+
|
|
167
|
+
def create(self, params=None, idempotency_key=None):
|
|
168
|
+
"""Add a store. (POST /api/v1/brand/stores)"""
|
|
169
|
+
return self._client.request("POST", "brand/stores", params, idempotency_key)
|
|
170
|
+
|
|
171
|
+
def update(self, store_id, params=None, idempotency_key=None):
|
|
172
|
+
"""Edit a store. (POST /api/v1/brand/stores/{store_id})"""
|
|
173
|
+
return self._client.request("POST", "brand/stores/%s" % (_quote(store_id),), params, idempotency_key)
|
|
174
|
+
|
|
175
|
+
def delete(self, store_id, params=None, idempotency_key=None):
|
|
176
|
+
"""Remove a store. (DELETE /api/v1/brand/stores/{store_id})"""
|
|
177
|
+
return self._client.request("DELETE", "brand/stores/%s" % (_quote(store_id),), params, idempotency_key)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class BrandCards:
|
|
181
|
+
def __init__(self, client):
|
|
182
|
+
self._client = client
|
|
183
|
+
|
|
184
|
+
def lookup(self, params=None, idempotency_key=None):
|
|
185
|
+
"""Look up one of your printed cards before activating it. (POST /api/v1/brand/cards/lookup)"""
|
|
186
|
+
return self._client.request("POST", "brand/cards/lookup", params, idempotency_key)
|
|
187
|
+
|
|
188
|
+
def activate(self, params=None, idempotency_key=None):
|
|
189
|
+
"""Activate a printed card: sells a PIN for the amount loaded. (POST /api/v1/brand/cards/activate)"""
|
|
190
|
+
return self._client.request("POST", "brand/cards/activate", params, idempotency_key)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class BrandIssuingCatalog:
|
|
194
|
+
def __init__(self, client):
|
|
195
|
+
self._client = client
|
|
196
|
+
|
|
197
|
+
def list(self, params=None, idempotency_key=None):
|
|
198
|
+
"""The products you can sell. (GET /api/v1/brand/issuing-catalog)"""
|
|
199
|
+
return self._client.request("GET", "brand/issuing-catalog", params, idempotency_key, list_=True)
|
|
200
|
+
|
|
201
|
+
def get(self, catalog_id, params=None, idempotency_key=None):
|
|
202
|
+
"""One product you can sell. (GET /api/v1/brand/issuing-catalog/{catalog_id})"""
|
|
203
|
+
return self._client.request("GET", "brand/issuing-catalog/%s" % (_quote(catalog_id),), params, idempotency_key)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class BrandPins:
|
|
207
|
+
def __init__(self, client):
|
|
208
|
+
self._client = client
|
|
209
|
+
|
|
210
|
+
def sell(self, params=None, idempotency_key=None):
|
|
211
|
+
"""Sell a PIN. The response has its full code (pin_code), returned only here. (POST /api/v1/brand/pins)"""
|
|
212
|
+
return self._client.request("POST", "brand/pins", params, idempotency_key)
|
|
213
|
+
|
|
214
|
+
def get(self, pin_id, params=None, idempotency_key=None):
|
|
215
|
+
"""One of your PINs: balance, status, masks, customer, family cards. (GET /api/v1/brand/pins/{pin_id})"""
|
|
216
|
+
return self._client.request("GET", "brand/pins/%s" % (_quote(pin_id),), params, idempotency_key)
|
|
217
|
+
|
|
218
|
+
def movements(self, pin_id, params=None, idempotency_key=None):
|
|
219
|
+
"""A PIN's movements, newest first. (GET /api/v1/brand/pins/{pin_id}/movements)"""
|
|
220
|
+
return self._client.request("GET", "brand/pins/%s/movements" % (_quote(pin_id),), params, idempotency_key, list_=True)
|
|
221
|
+
|
|
222
|
+
def top_up(self, params=None, idempotency_key=None):
|
|
223
|
+
"""Add value to a PIN. (POST /api/v1/brand/pins/top-up)"""
|
|
224
|
+
return self._client.request("POST", "brand/pins/top-up", params, idempotency_key)
|
|
225
|
+
|
|
226
|
+
def block(self, params=None, idempotency_key=None):
|
|
227
|
+
"""Block (freeze) a PIN. Its cardholder can't undo it. (POST /api/v1/brand/pins/block)"""
|
|
228
|
+
return self._client.request("POST", "brand/pins/block", params, idempotency_key)
|
|
229
|
+
|
|
230
|
+
def unblock(self, params=None, idempotency_key=None):
|
|
231
|
+
"""Unblock a PIN. (POST /api/v1/brand/pins/unblock)"""
|
|
232
|
+
return self._client.request("POST", "brand/pins/unblock", params, idempotency_key)
|
|
233
|
+
|
|
234
|
+
def new_code(self, params=None, idempotency_key=None):
|
|
235
|
+
"""Give a PIN a new code; the old one stops working. (POST /api/v1/brand/pins/new-code)"""
|
|
236
|
+
return self._client.request("POST", "brand/pins/new-code", params, idempotency_key)
|
|
237
|
+
|
|
238
|
+
def wallet_links(self, params=None, idempotency_key=None):
|
|
239
|
+
"""Links that add a PIN to Google Wallet or Apple Wallet. (POST /api/v1/brand/pins/wallet-links)"""
|
|
240
|
+
return self._client.request("POST", "brand/pins/wallet-links", params, idempotency_key)
|
|
241
|
+
|
|
242
|
+
def set_webhook(self, params=None, idempotency_key=None):
|
|
243
|
+
"""Change a PIN's webhook. (POST /api/v1/brand/pins/webhook)"""
|
|
244
|
+
return self._client.request("POST", "brand/pins/webhook", params, idempotency_key)
|
|
245
|
+
|
|
246
|
+
def check(self, params=None, idempotency_key=None):
|
|
247
|
+
"""Check a PIN before redeeming it; nothing changes. (POST /api/v1/brand/pins/check)"""
|
|
248
|
+
return self._client.request("POST", "brand/pins/check", params, idempotency_key)
|
|
249
|
+
|
|
250
|
+
def redeem(self, params=None, idempotency_key=None):
|
|
251
|
+
"""Redeem a PIN: take an amount off it and credit your wallet. (POST /api/v1/brand/pins/redeem)"""
|
|
252
|
+
return self._client.request("POST", "brand/pins/redeem", params, idempotency_key)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class BrandCatalog:
|
|
256
|
+
def __init__(self, client):
|
|
257
|
+
self._client = client
|
|
258
|
+
|
|
259
|
+
def list(self, params=None, idempotency_key=None):
|
|
260
|
+
"""The products you accept. (GET /api/v1/brand/catalog)"""
|
|
261
|
+
return self._client.request("GET", "brand/catalog", params, idempotency_key, list_=True)
|
|
262
|
+
|
|
263
|
+
def get(self, catalog_id, params=None, idempotency_key=None):
|
|
264
|
+
"""One product you accept. (GET /api/v1/brand/catalog/{catalog_id})"""
|
|
265
|
+
return self._client.request("GET", "brand/catalog/%s" % (_quote(catalog_id),), params, idempotency_key)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class BrandResellerStores:
|
|
269
|
+
def __init__(self, client):
|
|
270
|
+
self._client = client
|
|
271
|
+
|
|
272
|
+
def list(self, params=None, idempotency_key=None):
|
|
273
|
+
"""Reseller stores that sell PINs you accept. (GET /api/v1/brand/reseller-stores)"""
|
|
274
|
+
return self._client.request("GET", "brand/reseller-stores", params, idempotency_key, list_=True)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class BrandPayments:
|
|
278
|
+
def __init__(self, client):
|
|
279
|
+
self._client = client
|
|
280
|
+
|
|
281
|
+
def list(self, params=None, idempotency_key=None):
|
|
282
|
+
"""The redemptions credited to you, newest first. (GET /api/v1/brand/payments)"""
|
|
283
|
+
return self._client.request("GET", "brand/payments", params, idempotency_key, list_=True)
|
|
284
|
+
|
|
285
|
+
def get(self, transaction_ref, params=None, idempotency_key=None):
|
|
286
|
+
"""One redemption. (GET /api/v1/brand/payments/{transaction_ref})"""
|
|
287
|
+
return self._client.request("GET", "brand/payments/%s" % (_quote(transaction_ref),), params, idempotency_key)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class BrandQuotes:
|
|
291
|
+
def __init__(self, client):
|
|
292
|
+
self._client = client
|
|
293
|
+
|
|
294
|
+
def list(self, params=None, idempotency_key=None):
|
|
295
|
+
"""Your quotes, newest first. (GET /api/v1/brand/quotes)"""
|
|
296
|
+
return self._client.request("GET", "brand/quotes", params, idempotency_key, list_=True)
|
|
297
|
+
|
|
298
|
+
def create(self, params=None, idempotency_key=None):
|
|
299
|
+
"""Quote a business for your gift cards in bulk; returns a pay_url. (POST /api/v1/brand/quotes)"""
|
|
300
|
+
return self._client.request("POST", "brand/quotes", params, idempotency_key)
|
|
301
|
+
|
|
302
|
+
def get(self, quote_id, params=None, idempotency_key=None):
|
|
303
|
+
"""One quote, by id or QTE- reference. (GET /api/v1/brand/quotes/{quote_id})"""
|
|
304
|
+
return self._client.request("GET", "brand/quotes/%s" % (_quote(quote_id),), params, idempotency_key)
|
|
305
|
+
|
|
306
|
+
def cancel(self, quote_id, params=None, idempotency_key=None):
|
|
307
|
+
"""Withdraw an unpaid quote. (POST /api/v1/brand/quotes/{quote_id}/cancel)"""
|
|
308
|
+
return self._client.request("POST", "brand/quotes/%s/cancel" % (_quote(quote_id),), params, idempotency_key)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class Brand:
|
|
312
|
+
"""The brand API (/api/v1/brand): every call a brand account can make, by area."""
|
|
313
|
+
|
|
314
|
+
def __init__(self, client):
|
|
315
|
+
self.wallets = BrandWallets(client)
|
|
316
|
+
self.stores = BrandStores(client)
|
|
317
|
+
self.cards = BrandCards(client)
|
|
318
|
+
self.issuing_catalog = BrandIssuingCatalog(client)
|
|
319
|
+
self.pins = BrandPins(client)
|
|
320
|
+
self.catalog = BrandCatalog(client)
|
|
321
|
+
self.reseller_stores = BrandResellerStores(client)
|
|
322
|
+
self.payments = BrandPayments(client)
|
|
323
|
+
self.quotes = BrandQuotes(client)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _quote(value):
|
|
327
|
+
from urllib.parse import quote
|
|
328
|
+
|
|
329
|
+
return quote(str(value), safe="")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Checking a webhook AfroPin sent you. Each one is signed with the webhook's secret: the webhook-secret header holds the
|
|
3
|
+
HMAC-SHA256 of the raw body. Check it against the body exactly as received (request.get_data() in Flask, request.body in
|
|
4
|
+
Django), before parsing it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from .errors import AfroPinError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def verify(raw_body, signature, secret):
|
|
15
|
+
if not signature:
|
|
16
|
+
return False
|
|
17
|
+
body = raw_body if isinstance(raw_body, bytes) else raw_body.encode()
|
|
18
|
+
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
|
19
|
+
return hmac.compare_digest(expected, signature)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse(raw_body, signature, secret):
|
|
23
|
+
"""The event, {"type": "pin.sold", "data": {...}}, once its signature checks out."""
|
|
24
|
+
if not verify(raw_body, signature, secret):
|
|
25
|
+
raise AfroPinError("invalid_signature", "The webhook signature does not match its body")
|
|
26
|
+
event = json.loads(raw_body)
|
|
27
|
+
if not isinstance(event, dict) or "type" not in event:
|
|
28
|
+
raise AfroPinError("invalid_response", "The webhook body is not an AfroPin event")
|
|
29
|
+
return event
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: afropin
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python library for the AfroPin gift card API: sell, top up and redeem PINs, quotes, stores and webhooks
|
|
5
|
+
Author: AfroPin Software Limited
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://afropin.com/developers
|
|
8
|
+
Project-URL: Documentation, https://afropin.com/user/docs/introduction
|
|
9
|
+
Project-URL: Source, https://github.com/afropin/afropin-python
|
|
10
|
+
Keywords: afropin,gift cards,vouchers,pins,api
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# AfroPin for Python
|
|
21
|
+
|
|
22
|
+
The AfroPin gift card API from Python: sell, top up and redeem PINs, quotes, stores and webhooks.
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
pip install afropin
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Python 3.8 or newer. No dependencies.
|
|
29
|
+
|
|
30
|
+
## Start
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import os
|
|
34
|
+
from afropin import Client
|
|
35
|
+
|
|
36
|
+
afropin = Client(os.environ["AFROPIN_KEY_ID"], os.environ["AFROPIN_SECRET"])
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Your API key's mode decides whether you work with test or live records; the address is the same for both. Keep the
|
|
40
|
+
secret on your server, never in a browser or app.
|
|
41
|
+
|
|
42
|
+
### Reseller: sell a PIN
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
pin = afropin.reseller.pins.sell({
|
|
46
|
+
"catalog_id": catalog_id, # from afropin.reseller.catalog.list()
|
|
47
|
+
"amount": 5000,
|
|
48
|
+
"external_reference": order_id, # your own reference; reusing it fails safely
|
|
49
|
+
"first_name": "Ada",
|
|
50
|
+
"last_name": "Obi",
|
|
51
|
+
"email": "ada@example.com",
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
pin["pin_code"] # the full code: returned only here, so deliver it to your customer now
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Brand: check and redeem a PIN
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
check = afropin.brand.pins.check({"pin_code": code, "amount": 2500})
|
|
61
|
+
if check["can_redeem"]:
|
|
62
|
+
afropin.brand.pins.redeem({"pin_code": code, "amount": 2500, "external_reference": sale_id})
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Methods use snake_case (`top_up`, `new_code`, `wallet_links`); every call is listed in [METHODS.md](../METHODS.md).
|
|
66
|
+
Parameters and response fields are the API's own, as in the developer docs.
|
|
67
|
+
|
|
68
|
+
## Errors
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from afropin import AfroPinError
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
afropin.reseller.pins.top_up({"pin_id": pin_id, "amount": 1000, "external_reference": ref})
|
|
75
|
+
except AfroPinError as error:
|
|
76
|
+
error.code # for example pin_not_found, amount_too_large, duplicate_external_reference
|
|
77
|
+
error.fields # per-field messages for input problems
|
|
78
|
+
error.status # HTTP status, or 0 when the API wasn't reached (connection_failed)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Branch on `code`; the message is for people and may change.
|
|
82
|
+
|
|
83
|
+
## Lists
|
|
84
|
+
|
|
85
|
+
Lists return a `Page`: iterate it, and read `paging` for `page`, `pages` and `total`.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
page = afropin.brand.payments.list({"limit": 50})
|
|
89
|
+
for payment in page:
|
|
90
|
+
...
|
|
91
|
+
if page.has_more:
|
|
92
|
+
following = afropin.brand.payments.list({"limit": 50, "page": page.paging["page"] + 1})
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Idempotency and retries
|
|
96
|
+
|
|
97
|
+
Each request gets a new `Idempotency-Key`. To use your own: `sell(params, idempotency_key=key)`. Nothing is retried for
|
|
98
|
+
you. If a sale times out, send it again with the same `external_reference`: it either goes through once, or fails with
|
|
99
|
+
`duplicate_external_reference`.
|
|
100
|
+
|
|
101
|
+
## Webhooks
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from afropin import webhooks
|
|
105
|
+
|
|
106
|
+
# Flask
|
|
107
|
+
event = webhooks.parse(request.get_data(), request.headers.get("webhook-secret"), WEBHOOK_SECRET)
|
|
108
|
+
if event["type"] == "pin.spent":
|
|
109
|
+
...
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`parse` raises `invalid_signature` when the signature doesn't match. Use the raw body exactly as received.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
afropin/__init__.py
|
|
5
|
+
afropin/client.py
|
|
6
|
+
afropin/errors.py
|
|
7
|
+
afropin/page.py
|
|
8
|
+
afropin/resources.py
|
|
9
|
+
afropin/webhooks.py
|
|
10
|
+
afropin.egg-info/PKG-INFO
|
|
11
|
+
afropin.egg-info/SOURCES.txt
|
|
12
|
+
afropin.egg-info/dependency_links.txt
|
|
13
|
+
afropin.egg-info/top_level.txt
|
|
14
|
+
tests/test_sdk.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
afropin
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "afropin"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Python library for the AfroPin gift card API: sell, top up and redeem PINs, quotes, stores and webhooks"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
dependencies = []
|
|
13
|
+
authors = [{ name = "AfroPin Software Limited" }]
|
|
14
|
+
keywords = ["afropin", "gift cards", "vouchers", "pins", "api"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://afropin.com/developers"
|
|
24
|
+
Documentation = "https://afropin.com/user/docs/introduction"
|
|
25
|
+
Source = "https://github.com/afropin/afropin-python"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools]
|
|
28
|
+
packages = ["afropin"]
|
afropin-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The Python SDK's checks: signing and webhooks offline, then (with AFROPIN_TEST_ENV pointing at a JSON file of test keys,
|
|
3
|
+
base_url, catalog_id and test_pin) a real round trip against an AfroPin API in test mode.
|
|
4
|
+
python3 -m unittest discover -s tests
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import secrets
|
|
12
|
+
import sys
|
|
13
|
+
import unittest
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
16
|
+
from afropin import AfroPinError, Client, Page, webhooks # noqa: E402
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Offline(unittest.TestCase):
|
|
20
|
+
def test_signs_requests_and_returns_data(self):
|
|
21
|
+
sent = {}
|
|
22
|
+
|
|
23
|
+
def transport(method, url, headers, body):
|
|
24
|
+
sent.update(method=method, url=url, headers=headers, body=body)
|
|
25
|
+
return 200, '{"ok":true,"data":{"pin_id":"p1"}}'
|
|
26
|
+
|
|
27
|
+
client = Client("key_1", "secret_1", base_url="https://example.test/api/v1", transport=transport)
|
|
28
|
+
data = client.reseller.pins.top_up({"pin_id": "p1", "amount": 500}, idempotency_key="idem-1")
|
|
29
|
+
h = sent["headers"]
|
|
30
|
+
canonical = ".".join(["POST", "api/v1/reseller/pins/top-up", h["X-Timestamp"], h["X-Nonce"], hashlib.sha256(sent["body"].encode()).hexdigest()])
|
|
31
|
+
self.assertEqual(h["X-Signature"], hmac.new(b"secret_1", canonical.encode(), hashlib.sha256).hexdigest())
|
|
32
|
+
self.assertEqual(h["Idempotency-Key"], "idem-1")
|
|
33
|
+
self.assertEqual(data, {"pin_id": "p1"})
|
|
34
|
+
client.reseller.pins.get("a/b")
|
|
35
|
+
self.assertTrue(sent["url"].endswith("/reseller/pins/a%2Fb"))
|
|
36
|
+
self.assertEqual(sent["body"], "")
|
|
37
|
+
|
|
38
|
+
def test_verifies_webhooks(self):
|
|
39
|
+
body = '{"type":"pin.sold","data":{"pin_id":"p1"}}'
|
|
40
|
+
self.assertTrue(webhooks.verify(body, hmac.new(b"whsec", body.encode(), hashlib.sha256).hexdigest(), "whsec"))
|
|
41
|
+
self.assertFalse(webhooks.verify(body, hmac.new(b"other", body.encode(), hashlib.sha256).hexdigest(), "whsec"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@unittest.skipUnless(os.environ.get("AFROPIN_TEST_ENV"), "set AFROPIN_TEST_ENV to run against an API")
|
|
45
|
+
class Live(unittest.TestCase):
|
|
46
|
+
def test_round_trip_in_test_mode(self):
|
|
47
|
+
env = json.load(open(os.environ["AFROPIN_TEST_ENV"]))
|
|
48
|
+
reseller = Client(env["reseller"]["key_id"], env["reseller"]["secret"], base_url=env["base_url"])
|
|
49
|
+
brand = Client(env["brand"]["key_id"], env["brand"]["secret"], base_url=env["base_url"])
|
|
50
|
+
|
|
51
|
+
self.assertTrue(len(reseller.reseller.wallets.list()) > 0)
|
|
52
|
+
catalog = reseller.reseller.catalog.list({"limit": 5})
|
|
53
|
+
self.assertIsInstance(catalog, Page)
|
|
54
|
+
self.assertTrue(len(catalog) > 0 and catalog.paging["total"] > 0)
|
|
55
|
+
product = reseller.reseller.catalog.get(env["catalog_id"])
|
|
56
|
+
amount = float((product.get("denominations") or [product["min"]])[0])
|
|
57
|
+
sold = reseller.reseller.pins.sell({
|
|
58
|
+
"catalog_id": env["catalog_id"], "amount": amount, "external_reference": "sdk-py-" + secrets.token_hex(6),
|
|
59
|
+
"first_name": "Sdk", "last_name": "Test", "email": "sdk.test@gmail.com",
|
|
60
|
+
})
|
|
61
|
+
self.assertTrue(sold["pin_code"] and sold["pin_id"])
|
|
62
|
+
self.assertEqual(float(reseller.reseller.pins.get(sold["pin_id"])["balance"]), amount)
|
|
63
|
+
with self.assertRaises(AfroPinError) as caught:
|
|
64
|
+
reseller.reseller.pins.get("00000000-0000-4000-8000-000000000000")
|
|
65
|
+
self.assertEqual((caught.exception.code, caught.exception.status), ("pin_not_found", 404))
|
|
66
|
+
self.assertIn("can_redeem", brand.brand.pins.check({"pin_code": env["test_pin"]}))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
unittest.main()
|