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 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.
@@ -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
+ 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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()