payriff 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.
@@ -0,0 +1,4 @@
1
+ # Copy to .env and fill in. Never commit the real file.
2
+ PAYRIFF_SECRET_KEY=
3
+ PAYRIFF_MERCHANT_ID=
4
+ PAYRIFF_BASE_URL=https://api.payriff.com
@@ -0,0 +1,44 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ concurrency:
9
+ group: ${{ github.workflow }}-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ jobs:
13
+ test:
14
+ runs-on: ubuntu-latest
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ python: ["3.10", "3.11", "3.12", "3.13"]
19
+ steps:
20
+ - uses: actions/checkout@v5
21
+ - uses: astral-sh/setup-uv@v7
22
+ with:
23
+ enable-cache: true
24
+ python-version: ${{ matrix.python }}
25
+ - run: uv sync
26
+ - run: uv run ruff format --check .
27
+ - run: uv run ruff check .
28
+ - run: uv run mypy src
29
+ - run: uv run pytest -m "not sandbox" --cov --cov-report=term-missing
30
+
31
+ sandbox:
32
+ runs-on: ubuntu-latest
33
+ steps:
34
+ - uses: actions/checkout@v5
35
+ - uses: astral-sh/setup-uv@v7
36
+ with:
37
+ enable-cache: true
38
+ python-version: "3.12"
39
+ - run: uv sync
40
+ - name: Run against the Payriff sandbox
41
+ run: uv run pytest -m sandbox -v
42
+ env:
43
+ PAYRIFF_SECRET_KEY: ${{ secrets.PAYRIFF_SECRET_KEY }}
44
+ PAYRIFF_MERCHANT_ID: ${{ secrets.PAYRIFF_MERCHANT_ID }}
@@ -0,0 +1,17 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write
13
+ steps:
14
+ - uses: actions/checkout@v5
15
+ - uses: astral-sh/setup-uv@v7
16
+ - run: uv build
17
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,14 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .mypy_cache/
7
+ .coverage
8
+ dist/
9
+ build/
10
+ *.egg-info/
11
+ .env
12
+ .env.*
13
+ !.env.example
14
+ dist/
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release.
6
+
7
+ - `PayriffClient` and `AsyncPayriffClient`, covering create order, pre-authorisation, capture,
8
+ refund, order lookup, auto pay, card save, wallet transfer, and invoice create and lookup.
9
+ - Enums for every value the docs define, plus the numeric lookups that go with currencies and
10
+ payment statuses.
11
+ - `Response.payment_status` and `Response.settled`, so you can tell a settled payment apart from
12
+ a call that was merely accepted.
13
+ - `request` as an escape hatch for endpoints that aren't typed yet.
14
+ - Typed throughout, with `httpx` as the only dependency.
15
+ - Sandbox tests that run when a secret key is available and skip when it isn't.
payriff-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 martian56
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.
payriff-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,354 @@
1
+ Metadata-Version: 2.4
2
+ Name: payriff
3
+ Version: 0.1.0
4
+ Summary: Python client for the Payriff payment gateway
5
+ Project-URL: Homepage, https://github.com/martian56/payriff
6
+ Project-URL: Repository, https://github.com/martian56/payriff
7
+ Author: martian56
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: azerbaijan,gateway,payment,payriff,sdk
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Office/Business :: Financial
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: httpx>=0.27
22
+ Description-Content-Type: text/markdown
23
+
24
+ # payriff
25
+
26
+ Python client for the [Payriff](https://payriff.com) payment gateway. Sync and async, typed
27
+ throughout, with `httpx` as the only dependency.
28
+
29
+ ```bash
30
+ pip install payriff
31
+ ```
32
+
33
+ ## Quick start
34
+
35
+ ```python
36
+ from payriff import PayriffClient
37
+
38
+ client = PayriffClient("your-secret-key")
39
+
40
+ order = client.create_order(
41
+ amount=25.50,
42
+ description="Order 1024",
43
+ callback_url="https://shop.example/payriff/callback",
44
+ )
45
+ print(order.payment_url)
46
+ ```
47
+
48
+ Send the customer to `order.payment_url`. Payriff posts to your `callback_url` as the status
49
+ changes.
50
+
51
+ ## A whole checkout
52
+
53
+ The shape of a real integration, here with Flask. Two routes: one to start the payment, one to
54
+ receive the result.
55
+
56
+ ```python
57
+ from flask import Flask, jsonify, redirect, request
58
+
59
+ from payriff import GatewayError, PayriffClient
60
+
61
+ app = Flask(__name__)
62
+ client = PayriffClient.from_env()
63
+
64
+
65
+ @app.post("/checkout/<cart_id>")
66
+ def checkout(cart_id):
67
+ cart = load_cart(cart_id)
68
+ try:
69
+ order = client.create_order(
70
+ amount=cart.total,
71
+ description=f"Order {cart_id}",
72
+ callback_url="https://shop.example/payriff/callback",
73
+ )
74
+ except GatewayError as exc:
75
+ app.logger.warning("payriff refused the order: %s %s", exc.code, exc.message)
76
+ return jsonify(error="could not start the payment"), 502
77
+
78
+ cart.payriff_order_id = order.order_id
79
+ cart.save()
80
+ return redirect(order.payment_url)
81
+
82
+
83
+ @app.post("/payriff/callback")
84
+ def callback():
85
+ event = client.parse_callback(request.get_json(force=True))
86
+
87
+ # The callback is unsigned, so nothing here is trusted until the API agrees.
88
+ order = client.get_order(event.order_id)
89
+ cart = load_cart_by_order(event.order_id)
90
+
91
+ if order.settled:
92
+ cart.mark_paid()
93
+ elif order.payment_status in {"DECLINED", "CANCELED"}:
94
+ cart.mark_failed(order.payment_status)
95
+
96
+ return "", 200
97
+ ```
98
+
99
+ Two details worth copying. The callback handler decides on `order`, the value it fetched, never
100
+ on `event`, the value it was sent. And it answers `200` whatever the outcome, because a non-2xx
101
+ tells Payriff to deliver the same event again.
102
+
103
+ Callbacks can arrive more than once for one order, so make `mark_paid` idempotent.
104
+
105
+ ## Two things that catch people out
106
+
107
+ ### A success code doesn't mean the payment worked
108
+
109
+ The `code` field tells you whether Payriff accepted the call. Whether money actually moved is a
110
+ separate field, `paymentStatus`. `auto_pay` is where this hurts most: a declined card still comes
111
+ back as `00000`.
112
+
113
+ ```python
114
+ charge = client.auto_pay(card_uuid, 10.00, "Subscription")
115
+
116
+ charge.ok # True
117
+ charge.payment_status # "CANCELED"
118
+ charge.settled # False
119
+ ```
120
+
121
+ Gate your fulfilment on `settled`.
122
+
123
+ A hold is different again. A genuine pre-authorisation reports `PREAUTH_APPROVED`, and that is
124
+ the only status where money is reserved rather than taken. `settled` stays `False` for it and
125
+ `authorized` turns `True`.
126
+
127
+ ```python
128
+ hold = client.get_order(order_id)
129
+ hold.authorized # True, the funds are reserved
130
+ hold.settled # False, nothing has moved yet
131
+ ```
132
+
133
+ Watch out for accounts without pre-authorisation enabled. There, an order sent with
134
+ `operation=PRE_AUTH` is charged like an ordinary sale and comes back `APPROVED`, which `settled`
135
+ correctly reports as paid. Check `authorized`, not the operation you asked for.
136
+
137
+ ### Callbacks aren't signed
138
+
139
+ There is no HMAC and no shared secret in the body, so there's nothing to verify. Anyone who
140
+ learns your callback URL can post to it. Treat the callback as a nudge to go ask the API what
141
+ happened:
142
+
143
+ ```python
144
+ callback = client.parse_callback(request.json)
145
+ order = client.get_order(callback.order_id)
146
+
147
+ if order.settled:
148
+ fulfil(callback.order_id)
149
+ ```
150
+
151
+ `parse_callback` reads the body and nothing more. The `get_order` call is what makes it safe.
152
+
153
+ ## Configuration
154
+
155
+ ```python
156
+ client = PayriffClient(
157
+ "your-secret-key",
158
+ merchant_id="ES100000",
159
+ currency="AZN",
160
+ language="EN",
161
+ callback_url="https://shop.example/payriff/callback",
162
+ )
163
+ ```
164
+
165
+ `PayriffClient.from_env()` reads `PAYRIFF_SECRET_KEY`, `PAYRIFF_MERCHANT_ID`, `PAYRIFF_BASE_URL`
166
+ and `PAYRIFF_CALLBACK_URL` instead.
167
+
168
+ The secret goes into the `Authorization` header raw, with no `Bearer` prefix. The client handles
169
+ that for you.
170
+
171
+ ## Async
172
+
173
+ Every method has an awaitable twin:
174
+
175
+ ```python
176
+ from payriff import AsyncPayriffClient
177
+
178
+ async with AsyncPayriffClient("your-secret-key") as client:
179
+ order = await client.create_order(25.50, "Order 1024")
180
+ status = await client.get_order(order.order_id)
181
+ ```
182
+
183
+ ## Methods
184
+
185
+ | Method | Endpoint |
186
+ |---|---|
187
+ | `create_order` | `POST /api/v3/orders` |
188
+ | `reserve` | `POST /api/v3/orders` with `operation=PRE_AUTH` |
189
+ | `get_order` | `GET /api/v3/orders/{id}` |
190
+ | `complete` | `POST /api/v3/complete` |
191
+ | `refund` | `POST /api/v3/refund` |
192
+ | `auto_pay` | `POST /api/v3/autoPay` |
193
+ | `save_card`, `refund_card_save` | the two step card save flow |
194
+ | `transfer` | `POST /api/v3/payout` |
195
+ | `create_invoice` | `POST /api/v2/invoices` |
196
+ | `get_invoice` | `POST /api/v2/get-invoice` |
197
+
198
+ A couple of things worth knowing. The docs describe order information as a `POST`, but the API
199
+ answers that with a 405 and only takes `GET`, which is what this does. And `/api/v3/payout` moves
200
+ money between Payriff merchant wallets, so `transfer` seemed the honest name for it; if you were
201
+ hoping for a bank payout, this isn't it.
202
+
203
+ The v2 invoice endpoints want `merchant` set and refuse the call without it. The v3 order
204
+ endpoints do not care, so set `merchant_id` on the client if you touch invoices.
205
+
206
+ Bulk invoice and bulk payout are dashboard features driven by spreadsheet upload. There is no API
207
+ behind them, so there's nothing here for them either.
208
+
209
+ For anything else, go straight at it:
210
+
211
+ ```python
212
+ client.request("POST", "/api/v3/directPay", {...})
213
+ ```
214
+
215
+ ## Pre-authorisation
216
+
217
+ `reserve` puts a hold on the card instead of charging it. You have 30 days to capture with
218
+ `complete`, after which the hold expires on its own. Capture less than you held and Payriff
219
+ releases the difference.
220
+
221
+ ```python
222
+ hold = client.reserve(100.00, "Hotel booking", three_ds=True)
223
+ client.complete(hold.order_id, 80.00)
224
+ ```
225
+
226
+ ## Saving a card
227
+
228
+ To verify a card, Payriff charges 0.01 AZN and expects you to hand it straight back. Skipping the
229
+ refund leaves the customer out of pocket, so treat it as step two rather than tidying up:
230
+
231
+ ```python
232
+ verification = client.save_card(callback_url="https://shop.example/payriff/callback")
233
+ # the customer completes verification.payment_url, then
234
+ client.refund_card_save(verification.order_id)
235
+ ```
236
+
237
+ The `cardUuid` you need for `auto_pay` arrives in the callback.
238
+
239
+ ## Responses
240
+
241
+ Branch on `code` rather than the HTTP status. The interesting fields live under `payload`, and
242
+ `Response` reads through to them:
243
+
244
+ ```python
245
+ order.code # "00000"
246
+ order.code_name # "SUCCESS"
247
+ order.ok # the call was accepted
248
+ order.settled # money actually moved
249
+ order.authorized # funds held by a pre-authorisation, not yet captured
250
+ order.operation_type # "PURCHASE", "PRE_AUTH", ...
251
+ order.order_id, order.session_id, order.transaction_id, order.payment_url
252
+ order.payment_status # "PAID", "CANCELED", ...
253
+ order.transactions # per attempt: card mask, RRN, channel
254
+ order.payload # raw payload, occasionally a plain string such as "APPROVED"
255
+ order.raw # untouched body
256
+ ```
257
+
258
+ ## Enums
259
+
260
+ Values are case sensitive and always go over the wire as strings.
261
+
262
+ ```python
263
+ from payriff import Currency, Language, PaymentStatus
264
+
265
+ client.create_order(10, "x", currency=Currency.USD, language=Language.AZ)
266
+
267
+ if order.payment_status == PaymentStatus.APPROVED:
268
+ ...
269
+ ```
270
+
271
+ | Enum | Values |
272
+ |---|---|
273
+ | `Operation` | PURCHASE, PRE_AUTH, COMPLETE, REFUND, REVERSE |
274
+ | `Language` | AZ, EN, RU, AR |
275
+ | `Currency` | AZN, USD, EUR, PKR, AED, SAR |
276
+ | `PaymentType` | ONETIME, DAILY, WEEKLY, MONTHLY, ANNUALLY |
277
+ | `InstallmentProduct` | BIRKART, ALBALI, BOLKART, TAMKART |
278
+ | `InvoiceStatus` | PENDING, ERROR, EXPIRED, PARTIAL, COMPLETE, CASH |
279
+ | `PaymentStatus` | CREATED, APPROVED, CANCELED, DECLINED, REFUNDED, PREAUTH_APPROVED, EXPIRED, REVERSE, PARTIAL_REFUND, PARTIAL, ACCEPTED, REFUND_IN_PROGRESS, CASH, PENDING, PREAUTH_EXPIRED |
280
+ | `ResultCode` | SUCCESS, WARNING, ERROR, INVALID_PARAMETERS, UNAUTHORIZED, TOKEN_NOT_PRESENT, INVALID_TOKEN, INVALID_ORIGIN, CHECKING |
281
+ | `GatewayResult` | 00, APPROVED, PREAUTH-APPROVED |
282
+
283
+ Members hash by value, so a raw string off the wire and an enum member reach the same dict entry.
284
+ `CURRENCY_NUMERIC` and `PAYMENT_STATUS_CODES` give you the ISO 4217 and internal numeric codes.
285
+
286
+ `PaymentStatus` carries two extras, `PAID` and `COMPLETED`. Payriff's enum reference leaves them
287
+ out, but the order information and autoPay examples both return them, so they are in here.
288
+ `SETTLED_PAYMENT_STATUSES` and `HELD_PAYMENT_STATUSES` are the sets behind `settled` and
289
+ `authorized`.
290
+
291
+ ## Errors
292
+
293
+ `GatewayError` means Payriff refused the call: any result code other than `00000` or `01000`, or
294
+ a `4xx` that still carried an envelope. It gives you `code`, `message`, `internal_message` and
295
+ `response_id`.
296
+
297
+ `TransportError` covers the rest, so network failures and responses that either weren't JSON or
298
+ carried no envelope at all.
299
+
300
+ Both subclass `PayriffError` if you want to catch broadly.
301
+
302
+ ```python
303
+ from payriff import GatewayError, PayriffError, TransportError
304
+
305
+ try:
306
+ order = client.create_order(25.50, "Order 1024")
307
+ except GatewayError as exc:
308
+ # Payriff answered and said no. exc.code tells you why.
309
+ log.warning("refused: %s %s", exc.code, exc.message)
310
+ except TransportError as exc:
311
+ # Nothing came back. Safe to retry.
312
+ log.error("payriff unreachable: %s", exc)
313
+ ```
314
+
315
+ Retry `TransportError` if you like, but not `GatewayError`: the gateway already made up its mind
316
+ and the same call will be refused again.
317
+
318
+ The `01000` code is a bit odd. On a `2xx` it's a warning and comes back as a normal response. On
319
+ a `4xx` it's a refusal and raises, which is how Payriff answers an unknown application key.
320
+
321
+ ## Testing
322
+
323
+ Payriff runs a sandbox. Point `PAYRIFF_BASE_URL` at it, use a sandbox key, and pay with the
324
+ published test cards:
325
+
326
+ | Brand | Number | Expiry | CVV | OTP |
327
+ |---|---|---|---|---|
328
+ | Visa | 4000007546012078 | 04/29 | 893 | 123456 |
329
+ | Mastercard | 5100007346013947 | 04/29 | 783 | 123456 |
330
+
331
+ `tests/test_sandbox.py` runs when `PAYRIFF_SECRET_KEY` is set and skips when it isn't, so a
332
+ checkout without a key still passes.
333
+
334
+ ## What has been checked against the live gateway
335
+
336
+ Payriff's docs turned out to disagree with the API in a few places, so the flows below were run
337
+ against a real account and the parsing is built from the recorded bodies.
338
+
339
+ | Verified | Inferred from the docs only |
340
+ |---|---|
341
+ | create order, and the CREATED to PENDING to APPROVED or DECLINED lifecycle | complete, a capture |
342
+ | that a pre-auth order settles outright on an account without pre-auth | auto pay |
343
+ | order lookup, which is a GET despite the docs saying POST | card save and its refund |
344
+ | refund, which answers with a null payload, on both a purchase and a pre-auth | invoice create and lookup |
345
+ | the callback envelope, for a declined purchase | wallet transfer |
346
+ | result codes 15400, 15000 and 01000, and the 4xx behaviour | |
347
+
348
+ Pre-authorisation, autopay and invoicing are not switched on for the account used, so those
349
+ paths could not be exercised. Treat the right hand column as untested and please report anything
350
+ that looks wrong.
351
+
352
+ ## Licence
353
+
354
+ MIT. Not affiliated with Payriff.