fqris 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.
- fqris-0.1.0/.gitignore +8 -0
- fqris-0.1.0/LICENSE +21 -0
- fqris-0.1.0/PKG-INFO +230 -0
- fqris-0.1.0/README.md +219 -0
- fqris-0.1.0/examples/topup.py +193 -0
- fqris-0.1.0/pyproject.toml +38 -0
- fqris-0.1.0/src/fqris/__init__.py +19 -0
- fqris-0.1.0/src/fqris/_client.py +48 -0
- fqris-0.1.0/src/fqris/_errors.py +44 -0
- fqris-0.1.0/src/fqris/_http.py +198 -0
- fqris-0.1.0/src/fqris/_object.py +32 -0
- fqris-0.1.0/src/fqris/_pagination.py +18 -0
- fqris-0.1.0/src/fqris/_transport.py +84 -0
- fqris-0.1.0/src/fqris/_webhook.py +81 -0
- fqris-0.1.0/src/fqris/py.typed +0 -0
- fqris-0.1.0/src/fqris/resources/__init__.py +0 -0
- fqris-0.1.0/src/fqris/resources/_base.py +25 -0
- fqris-0.1.0/src/fqris/resources/customers.py +41 -0
- fqris-0.1.0/src/fqris/resources/events.py +37 -0
- fqris-0.1.0/src/fqris/resources/invoices.py +75 -0
- fqris-0.1.0/src/fqris/resources/payment_links.py +50 -0
- fqris-0.1.0/src/fqris/resources/payments.py +67 -0
- fqris-0.1.0/src/fqris/resources/refunds.py +36 -0
- fqris-0.1.0/src/fqris/resources/sandbox.py +32 -0
- fqris-0.1.0/src/fqris/resources/webhook_endpoints.py +59 -0
- fqris-0.1.0/tests/conftest.py +26 -0
- fqris-0.1.0/tests/fake.py +52 -0
- fqris-0.1.0/tests/integration/test_integration.py +174 -0
- fqris-0.1.0/tests/test_docs_contract.py +50 -0
- fqris-0.1.0/tests/test_http.py +259 -0
- fqris-0.1.0/tests/test_object.py +64 -0
- fqris-0.1.0/tests/test_package.py +37 -0
- fqris-0.1.0/tests/test_pagination.py +19 -0
- fqris-0.1.0/tests/test_payments.py +141 -0
- fqris-0.1.0/tests/test_refunds.py +50 -0
- fqris-0.1.0/tests/test_resources.py +245 -0
- fqris-0.1.0/tests/test_routes_coverage.py +27 -0
- fqris-0.1.0/tests/test_topup_example.py +180 -0
- fqris-0.1.0/tests/test_urllib_client.py +147 -0
- fqris-0.1.0/tests/test_version.py +12 -0
- fqris-0.1.0/tests/test_webhook.py +121 -0
fqris-0.1.0/.gitignore
ADDED
fqris-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FQris
|
|
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.
|
fqris-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fqris
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SDK Python resmi untuk FQris REST API v1 (payment gateway QRIS).
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Typing :: Typed
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# fqris (Python)
|
|
13
|
+
|
|
14
|
+
SDK Python resmi untuk [FQris](https://fqris.id) (QRIS payment gateway). Nol dependensi runtime, Python >= 3.9, type hint lengkap (`py.typed`).
|
|
15
|
+
|
|
16
|
+
Dokumentasi API lengkap: https://fqris.id/docs.
|
|
17
|
+
|
|
18
|
+
## Instalasi
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install fqris
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Mulai cepat
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import os
|
|
28
|
+
import fqris
|
|
29
|
+
|
|
30
|
+
client = fqris.Client(os.environ["FQRIS_KEY"])
|
|
31
|
+
|
|
32
|
+
payment = client.payments.create(
|
|
33
|
+
amount=20000,
|
|
34
|
+
reference_id="TOPUP-88812",
|
|
35
|
+
)
|
|
36
|
+
print(payment.checkout_url)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Kunci API harus diawali `fq_live_` atau `fq_test_`. Simpan hanya di backend, jangan di aplikasi Android atau browser.
|
|
40
|
+
|
|
41
|
+
Respons berupa `FQrisObject`, sebuah `dict` yang juga bisa dibaca lewat atribut: `payment.checkout_url`, `event.data.object.id`, `p.metadata["user_id"]`. Kunci yang bentrok dengan metode `dict` (mis. `items` pada invoice) dibaca lewat `invoice["items"]`, bukan `invoice.items`.
|
|
42
|
+
|
|
43
|
+
### Opsi
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
client = fqris.Client(
|
|
47
|
+
key,
|
|
48
|
+
base_url="https://api.fqris.id", # bawaan
|
|
49
|
+
max_retries=2, # bawaan
|
|
50
|
+
timeout=30.0, # detik, bawaan
|
|
51
|
+
http_client=custom_http_client, # opsional, lihat "Transport kustom"
|
|
52
|
+
)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Body request dikirim sebagai keyword argument. Opsi per request (`idempotency_key`, `request_id`) bersifat keyword-only dan tidak ikut body.
|
|
56
|
+
|
|
57
|
+
## Retry dan idempotensi
|
|
58
|
+
|
|
59
|
+
SDK mengulang request yang gagal karena jaringan, timeout, `429`, atau `5xx` (sampai `max_retries` kali, dengan backoff; `Retry-After` dihormati sampai 60 detik).
|
|
60
|
+
|
|
61
|
+
Setiap POST membawa header `Idempotency-Key`. Kunci dibuat sekali sebelum percobaan pertama dan **sama di semua retry**, jadi payment tidak pernah terbuat dua kali. Untuk operasi yang kamu ulang sendiri (misalnya setelah proses backend mati), berikan kunci yang stabil:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
client.payments.create(
|
|
65
|
+
amount=20000,
|
|
66
|
+
reference_id="TOPUP-88812",
|
|
67
|
+
idempotency_key="TOPUP-88812",
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Kunci yang sama dengan isi berbeda ditolak `409 IDEMPOTENCY_KEY_REUSED`, jadi jangan memakai ulang kunci untuk body baru.
|
|
72
|
+
|
|
73
|
+
## Error
|
|
74
|
+
|
|
75
|
+
Respons non-2xx (setelah retry habis) menjadi `FQrisError`:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from fqris import FQrisError
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
client.payments.retrieve("pay_tidak_ada")
|
|
82
|
+
except FQrisError as e:
|
|
83
|
+
print(e.status, e.code, e, e.request_id) # sertakan request_id saat menghubungi dukungan
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Kolom yang tersedia: `status`, `type`, `code`, `param`, `request_id`, `doc_url`, `headers` (pesan = `str(e)`). Kegagalan jaringan memakai `status` 0 dan `code` `NETWORK_ERROR` atau `TIMEOUT`. ID resource kosong, `.` atau `..` melempar `ValueError` sebelum request dikirim.
|
|
87
|
+
|
|
88
|
+
## Paginasi
|
|
89
|
+
|
|
90
|
+
List mengembalikan `{ data, has_more, next_cursor }` dan menerima `limit` (maks 100), `starting_after`, `ending_before`, serta filter seperti `status` dan `created[gte]` (string ISO 8601, kirim lewat `**{"created[gte]": "..."}`).
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
page = client.payments.list(
|
|
94
|
+
limit=20,
|
|
95
|
+
status="paid",
|
|
96
|
+
**{"created[gte]": "2026-10-01T00:00:00Z"},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Atau iterasi semua, halaman berikutnya diambil otomatis:
|
|
100
|
+
for payment in client.payments.list_all(limit=100):
|
|
101
|
+
print(payment.id)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`list_all` tersedia di `payments`, `customers`, `payment_links`, `invoices`, dan `events`.
|
|
105
|
+
|
|
106
|
+
## Webhook
|
|
107
|
+
|
|
108
|
+
Verifikasi **raw body** sebelum di-parse. Flask: `request.get_data()`; Django: `request.body`. Jangan pakai `request.json` atau `json.loads(...)`:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from fqris import PaymentNotPaidError, SignatureVerificationError, Webhook
|
|
112
|
+
|
|
113
|
+
@app.post("/webhooks/fqris")
|
|
114
|
+
def fqris_webhook():
|
|
115
|
+
try:
|
|
116
|
+
# header FQris-Signature, toleransi bawaan 300 detik
|
|
117
|
+
event = Webhook.construct_event(
|
|
118
|
+
request.get_data(),
|
|
119
|
+
request.headers.get("FQris-Signature"),
|
|
120
|
+
os.environ["WHSEC"],
|
|
121
|
+
)
|
|
122
|
+
except SignatureVerificationError:
|
|
123
|
+
return "", 400
|
|
124
|
+
if event.type != "payment.paid":
|
|
125
|
+
return "", 200
|
|
126
|
+
if already_processed(event.id): # event bisa datang lebih dari sekali
|
|
127
|
+
return "", 200
|
|
128
|
+
|
|
129
|
+
# Ambil ulang status sebelum mengkredit. Hanya status `paid` yang lolos.
|
|
130
|
+
try:
|
|
131
|
+
payment = client.payments.retrieve_and_assert_paid(event.data.object.id)
|
|
132
|
+
except PaymentNotPaidError:
|
|
133
|
+
return "", 200 # mis. sudah refunded: jangan dikredit, jangan di-retry
|
|
134
|
+
# gangguan jaringan/5xx (FQrisError): biarkan 500 supaya FQris mengirim ulang
|
|
135
|
+
|
|
136
|
+
# Cocokkan dengan order sebelum mengkredit.
|
|
137
|
+
order = find_order((payment.get("metadata") or {}).get("order_id"))
|
|
138
|
+
if order is None or order.amount != payment.base_amount:
|
|
139
|
+
alarm_unmatched(payment) # uang sudah masuk: WAJIB ditindaklanjuti manual
|
|
140
|
+
mark_processed(event.id)
|
|
141
|
+
return "", 200
|
|
142
|
+
|
|
143
|
+
credit_coins_once(order, payment) # satu transaksi DB, lihat aturan di bawah
|
|
144
|
+
mark_processed(event.id)
|
|
145
|
+
return "", 200
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
- `Webhook.construct_event(payload, sig_header, secret, tolerance=300)` menerima `str` atau `bytes`. Dict hasil parse melempar `SignatureVerificationError` dengan petunjuk memakai raw body. Header `None` (tidak ada) juga `SignatureVerificationError`.
|
|
149
|
+
- Saat rotasi secret, header memuat dua `v1`; cocok dengan salah satunya sudah cukup.
|
|
150
|
+
- `retrieve_and_assert_paid(id)` melempar `PaymentNotPaidError` (membawa `error.payment`) untuk status selain `paid`, termasuk `refunded` dan `partially_refunded`, sehingga payment yang sudah direfund tidak dikredit. Tangkap error ini dan balas 200; kalau dibiarkan lolos, handler membalas 500 dan FQris mengirim ulang event yang sama terus-menerus.
|
|
151
|
+
- Balas 2xx dalam 10 detik dan kerjakan pekerjaan berat setelahnya. Event bisa datang lebih dari sekali dan tidak berurutan: simpan `event.id` yang sudah selesai diproses dan balas 200 untuk duplikat, serta beri kunci unik per `payment_id` dan per order pada kredit.
|
|
152
|
+
- Jangan mengkredit dari `return_url`: parameternya bisa dipalsukan.
|
|
153
|
+
- **Order tidak ditemukan atau nominal (`base_amount`) tidak cocok: cabang ini WAJIB diberi alarm atau tiket rekonsiliasi**, karena uang sudah masuk. Balas 200 (retry tidak membantu), tetapi jangan diam. Contoh top-up menyediakan callback `on_unmatched(payment, reason)` untuk ini (bawaan cetak ke stderr; ganti dengan alarm sungguhan).
|
|
154
|
+
|
|
155
|
+
### Satu order, satu kredit
|
|
156
|
+
|
|
157
|
+
Beri kunci unik per `payment_id` **dan** per order di DB, di dalam satu transaksi. Satu `reference_id` hanya untuk satu payment (`409 DUPLICATE_REFERENCE`), jadi untuk mencoba lagi kamu membuat payment baru dengan `reference_id` baru. Payment lama dan baru bisa sama-sama lunas: kredit yang pertama, lalu `refunds.create` untuk yang kedua. Membatalkan payment lama tidak cukup, karena payment `cancelled` masih bisa menjadi `paid` selama masa tenggang.
|
|
158
|
+
|
|
159
|
+
### paid_late dan paid_after_cancel
|
|
160
|
+
|
|
161
|
+
- `paid_late: true`: pembeli membayar setelah `expires_at`, dalam masa tenggang (bawaan 30 menit).
|
|
162
|
+
- `paid_after_cancel: true`: kamu membatalkan payment, tetapi pembeli membayar QR-nya dalam masa tenggang.
|
|
163
|
+
|
|
164
|
+
Keduanya datang sebagai `payment.paid` dan lolos `retrieve_and_assert_paid` karena statusnya `paid`. Ordermu mungkin sudah kamu tandai gagal: kredit atau refund sesuai kebijakanmu. Tandai order gagal hanya setelah masa tenggang habis.
|
|
165
|
+
|
|
166
|
+
## Sandbox
|
|
167
|
+
|
|
168
|
+
Pakai kunci `fq_test_`. Data test terpisah total dari live.
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
client = fqris.Client(os.environ["FQRIS_TEST_KEY"])
|
|
172
|
+
payment = client.payments.create(amount=20000, reference_id="TEST-1")
|
|
173
|
+
|
|
174
|
+
client.sandbox.simulate_payment(payment.id, result="paid") # atau "expired" | "paid_late"
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Refund
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
client.refunds.create(
|
|
181
|
+
"pay_01JA2Z3K8M",
|
|
182
|
+
amount=20137,
|
|
183
|
+
reason="customer_request",
|
|
184
|
+
note="Koin gagal masuk",
|
|
185
|
+
)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Transport kustom
|
|
189
|
+
|
|
190
|
+
Bawaannya `urllib` dari standard library. Untuk proxy, tes, atau library HTTP lain, berikan objek dengan metode `send(method, url, headers, body, timeout) -> HttpResponse` lewat `http_client=`. Respons non-2xx dikembalikan sebagai `HttpResponse` (bukan exception); kegagalan jaringan dilempar sebagai `TransportError`.
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from fqris import HttpResponse, TransportError
|
|
194
|
+
|
|
195
|
+
class MyHttp:
|
|
196
|
+
def send(self, method, url, headers, body, timeout) -> HttpResponse: ...
|
|
197
|
+
|
|
198
|
+
client = fqris.Client(key, http_client=MyHttp())
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Resource lain
|
|
202
|
+
|
|
203
|
+
`payments` (`create`, `retrieve`, `list`, `list_all`, `cancel`, `events`, `qr`, `retrieve_and_assert_paid`), `refunds`, `sandbox`, `customers`, `payment_links`, `invoices` (termasuk `finalize`, `void`, `send`, `pdf`), `webhook_endpoints` (termasuk `rotate_secret`, `test`), dan `events` (termasuk `resend`). Nama metode sama dengan SDK Node dalam snake_case; `del` menjadi `delete`.
|
|
204
|
+
|
|
205
|
+
`GET /v1/events/stream` (SSE) belum dibungkus SDK ini; panggil langsung dengan header `Authorization: Bearer <key>` bila perlu.
|
|
206
|
+
|
|
207
|
+
## Contoh: top-up koin
|
|
208
|
+
|
|
209
|
+
[`examples/topup.py`](./examples/topup.py) di repositori memuat alur lengkap sesuai panduan "Top-up koin": backend membuat payment (`reference_id` `TOPUP-<order>`, `metadata` berisi `user_id`), menerima webhook dengan raw body, mengambil ulang lewat `retrieve_and_assert_paid`, mencocokkan order dan nominal (tidak cocok -> `on_unmatched`), mengabaikan `event.id` ganda, mengkredit koin sekali per order, dan me-refund payment kedua yang lunas untuk order yang sama.
|
|
210
|
+
|
|
211
|
+
Ringkasnya:
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
topup = Topup(fqris.Client(os.environ["FQRIS_KEY"]), store, os.environ["WHSEC"])
|
|
215
|
+
|
|
216
|
+
# Aplikasi Android -> backend-mu: buat payment, buka checkout_url di Custom Tab.
|
|
217
|
+
@app.post("/topup")
|
|
218
|
+
def start():
|
|
219
|
+
return topup.start(current_user.id, "koin-200")
|
|
220
|
+
|
|
221
|
+
# FQris -> backend-mu.
|
|
222
|
+
@app.post("/webhooks/fqris")
|
|
223
|
+
def webhook():
|
|
224
|
+
code = topup.handle_webhook(request.get_data(), request.headers.get("FQris-Signature"))
|
|
225
|
+
return "", code
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Lisensi
|
|
229
|
+
|
|
230
|
+
MIT
|
fqris-0.1.0/README.md
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# fqris (Python)
|
|
2
|
+
|
|
3
|
+
SDK Python resmi untuk [FQris](https://fqris.id) (QRIS payment gateway). Nol dependensi runtime, Python >= 3.9, type hint lengkap (`py.typed`).
|
|
4
|
+
|
|
5
|
+
Dokumentasi API lengkap: https://fqris.id/docs.
|
|
6
|
+
|
|
7
|
+
## Instalasi
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install fqris
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Mulai cepat
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import os
|
|
17
|
+
import fqris
|
|
18
|
+
|
|
19
|
+
client = fqris.Client(os.environ["FQRIS_KEY"])
|
|
20
|
+
|
|
21
|
+
payment = client.payments.create(
|
|
22
|
+
amount=20000,
|
|
23
|
+
reference_id="TOPUP-88812",
|
|
24
|
+
)
|
|
25
|
+
print(payment.checkout_url)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Kunci API harus diawali `fq_live_` atau `fq_test_`. Simpan hanya di backend, jangan di aplikasi Android atau browser.
|
|
29
|
+
|
|
30
|
+
Respons berupa `FQrisObject`, sebuah `dict` yang juga bisa dibaca lewat atribut: `payment.checkout_url`, `event.data.object.id`, `p.metadata["user_id"]`. Kunci yang bentrok dengan metode `dict` (mis. `items` pada invoice) dibaca lewat `invoice["items"]`, bukan `invoice.items`.
|
|
31
|
+
|
|
32
|
+
### Opsi
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
client = fqris.Client(
|
|
36
|
+
key,
|
|
37
|
+
base_url="https://api.fqris.id", # bawaan
|
|
38
|
+
max_retries=2, # bawaan
|
|
39
|
+
timeout=30.0, # detik, bawaan
|
|
40
|
+
http_client=custom_http_client, # opsional, lihat "Transport kustom"
|
|
41
|
+
)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Body request dikirim sebagai keyword argument. Opsi per request (`idempotency_key`, `request_id`) bersifat keyword-only dan tidak ikut body.
|
|
45
|
+
|
|
46
|
+
## Retry dan idempotensi
|
|
47
|
+
|
|
48
|
+
SDK mengulang request yang gagal karena jaringan, timeout, `429`, atau `5xx` (sampai `max_retries` kali, dengan backoff; `Retry-After` dihormati sampai 60 detik).
|
|
49
|
+
|
|
50
|
+
Setiap POST membawa header `Idempotency-Key`. Kunci dibuat sekali sebelum percobaan pertama dan **sama di semua retry**, jadi payment tidak pernah terbuat dua kali. Untuk operasi yang kamu ulang sendiri (misalnya setelah proses backend mati), berikan kunci yang stabil:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
client.payments.create(
|
|
54
|
+
amount=20000,
|
|
55
|
+
reference_id="TOPUP-88812",
|
|
56
|
+
idempotency_key="TOPUP-88812",
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Kunci yang sama dengan isi berbeda ditolak `409 IDEMPOTENCY_KEY_REUSED`, jadi jangan memakai ulang kunci untuk body baru.
|
|
61
|
+
|
|
62
|
+
## Error
|
|
63
|
+
|
|
64
|
+
Respons non-2xx (setelah retry habis) menjadi `FQrisError`:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from fqris import FQrisError
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
client.payments.retrieve("pay_tidak_ada")
|
|
71
|
+
except FQrisError as e:
|
|
72
|
+
print(e.status, e.code, e, e.request_id) # sertakan request_id saat menghubungi dukungan
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Kolom yang tersedia: `status`, `type`, `code`, `param`, `request_id`, `doc_url`, `headers` (pesan = `str(e)`). Kegagalan jaringan memakai `status` 0 dan `code` `NETWORK_ERROR` atau `TIMEOUT`. ID resource kosong, `.` atau `..` melempar `ValueError` sebelum request dikirim.
|
|
76
|
+
|
|
77
|
+
## Paginasi
|
|
78
|
+
|
|
79
|
+
List mengembalikan `{ data, has_more, next_cursor }` dan menerima `limit` (maks 100), `starting_after`, `ending_before`, serta filter seperti `status` dan `created[gte]` (string ISO 8601, kirim lewat `**{"created[gte]": "..."}`).
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
page = client.payments.list(
|
|
83
|
+
limit=20,
|
|
84
|
+
status="paid",
|
|
85
|
+
**{"created[gte]": "2026-10-01T00:00:00Z"},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Atau iterasi semua, halaman berikutnya diambil otomatis:
|
|
89
|
+
for payment in client.payments.list_all(limit=100):
|
|
90
|
+
print(payment.id)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`list_all` tersedia di `payments`, `customers`, `payment_links`, `invoices`, dan `events`.
|
|
94
|
+
|
|
95
|
+
## Webhook
|
|
96
|
+
|
|
97
|
+
Verifikasi **raw body** sebelum di-parse. Flask: `request.get_data()`; Django: `request.body`. Jangan pakai `request.json` atau `json.loads(...)`:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from fqris import PaymentNotPaidError, SignatureVerificationError, Webhook
|
|
101
|
+
|
|
102
|
+
@app.post("/webhooks/fqris")
|
|
103
|
+
def fqris_webhook():
|
|
104
|
+
try:
|
|
105
|
+
# header FQris-Signature, toleransi bawaan 300 detik
|
|
106
|
+
event = Webhook.construct_event(
|
|
107
|
+
request.get_data(),
|
|
108
|
+
request.headers.get("FQris-Signature"),
|
|
109
|
+
os.environ["WHSEC"],
|
|
110
|
+
)
|
|
111
|
+
except SignatureVerificationError:
|
|
112
|
+
return "", 400
|
|
113
|
+
if event.type != "payment.paid":
|
|
114
|
+
return "", 200
|
|
115
|
+
if already_processed(event.id): # event bisa datang lebih dari sekali
|
|
116
|
+
return "", 200
|
|
117
|
+
|
|
118
|
+
# Ambil ulang status sebelum mengkredit. Hanya status `paid` yang lolos.
|
|
119
|
+
try:
|
|
120
|
+
payment = client.payments.retrieve_and_assert_paid(event.data.object.id)
|
|
121
|
+
except PaymentNotPaidError:
|
|
122
|
+
return "", 200 # mis. sudah refunded: jangan dikredit, jangan di-retry
|
|
123
|
+
# gangguan jaringan/5xx (FQrisError): biarkan 500 supaya FQris mengirim ulang
|
|
124
|
+
|
|
125
|
+
# Cocokkan dengan order sebelum mengkredit.
|
|
126
|
+
order = find_order((payment.get("metadata") or {}).get("order_id"))
|
|
127
|
+
if order is None or order.amount != payment.base_amount:
|
|
128
|
+
alarm_unmatched(payment) # uang sudah masuk: WAJIB ditindaklanjuti manual
|
|
129
|
+
mark_processed(event.id)
|
|
130
|
+
return "", 200
|
|
131
|
+
|
|
132
|
+
credit_coins_once(order, payment) # satu transaksi DB, lihat aturan di bawah
|
|
133
|
+
mark_processed(event.id)
|
|
134
|
+
return "", 200
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- `Webhook.construct_event(payload, sig_header, secret, tolerance=300)` menerima `str` atau `bytes`. Dict hasil parse melempar `SignatureVerificationError` dengan petunjuk memakai raw body. Header `None` (tidak ada) juga `SignatureVerificationError`.
|
|
138
|
+
- Saat rotasi secret, header memuat dua `v1`; cocok dengan salah satunya sudah cukup.
|
|
139
|
+
- `retrieve_and_assert_paid(id)` melempar `PaymentNotPaidError` (membawa `error.payment`) untuk status selain `paid`, termasuk `refunded` dan `partially_refunded`, sehingga payment yang sudah direfund tidak dikredit. Tangkap error ini dan balas 200; kalau dibiarkan lolos, handler membalas 500 dan FQris mengirim ulang event yang sama terus-menerus.
|
|
140
|
+
- Balas 2xx dalam 10 detik dan kerjakan pekerjaan berat setelahnya. Event bisa datang lebih dari sekali dan tidak berurutan: simpan `event.id` yang sudah selesai diproses dan balas 200 untuk duplikat, serta beri kunci unik per `payment_id` dan per order pada kredit.
|
|
141
|
+
- Jangan mengkredit dari `return_url`: parameternya bisa dipalsukan.
|
|
142
|
+
- **Order tidak ditemukan atau nominal (`base_amount`) tidak cocok: cabang ini WAJIB diberi alarm atau tiket rekonsiliasi**, karena uang sudah masuk. Balas 200 (retry tidak membantu), tetapi jangan diam. Contoh top-up menyediakan callback `on_unmatched(payment, reason)` untuk ini (bawaan cetak ke stderr; ganti dengan alarm sungguhan).
|
|
143
|
+
|
|
144
|
+
### Satu order, satu kredit
|
|
145
|
+
|
|
146
|
+
Beri kunci unik per `payment_id` **dan** per order di DB, di dalam satu transaksi. Satu `reference_id` hanya untuk satu payment (`409 DUPLICATE_REFERENCE`), jadi untuk mencoba lagi kamu membuat payment baru dengan `reference_id` baru. Payment lama dan baru bisa sama-sama lunas: kredit yang pertama, lalu `refunds.create` untuk yang kedua. Membatalkan payment lama tidak cukup, karena payment `cancelled` masih bisa menjadi `paid` selama masa tenggang.
|
|
147
|
+
|
|
148
|
+
### paid_late dan paid_after_cancel
|
|
149
|
+
|
|
150
|
+
- `paid_late: true`: pembeli membayar setelah `expires_at`, dalam masa tenggang (bawaan 30 menit).
|
|
151
|
+
- `paid_after_cancel: true`: kamu membatalkan payment, tetapi pembeli membayar QR-nya dalam masa tenggang.
|
|
152
|
+
|
|
153
|
+
Keduanya datang sebagai `payment.paid` dan lolos `retrieve_and_assert_paid` karena statusnya `paid`. Ordermu mungkin sudah kamu tandai gagal: kredit atau refund sesuai kebijakanmu. Tandai order gagal hanya setelah masa tenggang habis.
|
|
154
|
+
|
|
155
|
+
## Sandbox
|
|
156
|
+
|
|
157
|
+
Pakai kunci `fq_test_`. Data test terpisah total dari live.
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
client = fqris.Client(os.environ["FQRIS_TEST_KEY"])
|
|
161
|
+
payment = client.payments.create(amount=20000, reference_id="TEST-1")
|
|
162
|
+
|
|
163
|
+
client.sandbox.simulate_payment(payment.id, result="paid") # atau "expired" | "paid_late"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Refund
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
client.refunds.create(
|
|
170
|
+
"pay_01JA2Z3K8M",
|
|
171
|
+
amount=20137,
|
|
172
|
+
reason="customer_request",
|
|
173
|
+
note="Koin gagal masuk",
|
|
174
|
+
)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Transport kustom
|
|
178
|
+
|
|
179
|
+
Bawaannya `urllib` dari standard library. Untuk proxy, tes, atau library HTTP lain, berikan objek dengan metode `send(method, url, headers, body, timeout) -> HttpResponse` lewat `http_client=`. Respons non-2xx dikembalikan sebagai `HttpResponse` (bukan exception); kegagalan jaringan dilempar sebagai `TransportError`.
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
from fqris import HttpResponse, TransportError
|
|
183
|
+
|
|
184
|
+
class MyHttp:
|
|
185
|
+
def send(self, method, url, headers, body, timeout) -> HttpResponse: ...
|
|
186
|
+
|
|
187
|
+
client = fqris.Client(key, http_client=MyHttp())
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Resource lain
|
|
191
|
+
|
|
192
|
+
`payments` (`create`, `retrieve`, `list`, `list_all`, `cancel`, `events`, `qr`, `retrieve_and_assert_paid`), `refunds`, `sandbox`, `customers`, `payment_links`, `invoices` (termasuk `finalize`, `void`, `send`, `pdf`), `webhook_endpoints` (termasuk `rotate_secret`, `test`), dan `events` (termasuk `resend`). Nama metode sama dengan SDK Node dalam snake_case; `del` menjadi `delete`.
|
|
193
|
+
|
|
194
|
+
`GET /v1/events/stream` (SSE) belum dibungkus SDK ini; panggil langsung dengan header `Authorization: Bearer <key>` bila perlu.
|
|
195
|
+
|
|
196
|
+
## Contoh: top-up koin
|
|
197
|
+
|
|
198
|
+
[`examples/topup.py`](./examples/topup.py) di repositori memuat alur lengkap sesuai panduan "Top-up koin": backend membuat payment (`reference_id` `TOPUP-<order>`, `metadata` berisi `user_id`), menerima webhook dengan raw body, mengambil ulang lewat `retrieve_and_assert_paid`, mencocokkan order dan nominal (tidak cocok -> `on_unmatched`), mengabaikan `event.id` ganda, mengkredit koin sekali per order, dan me-refund payment kedua yang lunas untuk order yang sama.
|
|
199
|
+
|
|
200
|
+
Ringkasnya:
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
topup = Topup(fqris.Client(os.environ["FQRIS_KEY"]), store, os.environ["WHSEC"])
|
|
204
|
+
|
|
205
|
+
# Aplikasi Android -> backend-mu: buat payment, buka checkout_url di Custom Tab.
|
|
206
|
+
@app.post("/topup")
|
|
207
|
+
def start():
|
|
208
|
+
return topup.start(current_user.id, "koin-200")
|
|
209
|
+
|
|
210
|
+
# FQris -> backend-mu.
|
|
211
|
+
@app.post("/webhooks/fqris")
|
|
212
|
+
def webhook():
|
|
213
|
+
code = topup.handle_webhook(request.get_data(), request.headers.get("FQris-Signature"))
|
|
214
|
+
return "", code
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Lisensi
|
|
218
|
+
|
|
219
|
+
MIT
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Contoh top-up koin (PRD §13.6): backend membuat payment, menerima webhook, lalu mengkredit koin tepat sekali.
|
|
2
|
+
|
|
3
|
+
Tanpa dependensi framework: `Topup.handle_webhook` menerima raw body dan mengembalikan kode HTTP, jadi
|
|
4
|
+
pasangannya dengan Flask/Django cukup beberapa baris (lihat paling bawah). Di aplikasimu `import fqris`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Callable, Protocol
|
|
13
|
+
|
|
14
|
+
from fqris import (
|
|
15
|
+
Client,
|
|
16
|
+
FQrisObject,
|
|
17
|
+
PaymentNotPaidError,
|
|
18
|
+
SignatureVerificationError,
|
|
19
|
+
Webhook,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
PACKAGES = {"koin-200": {"coins": 200, "amount": 20000}}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Order:
|
|
27
|
+
id: str
|
|
28
|
+
user_id: str
|
|
29
|
+
coins: int
|
|
30
|
+
amount: int
|
|
31
|
+
status: str = "awaiting_payment" # 'awaiting_payment' | 'credited'
|
|
32
|
+
payment_id: str | None = None
|
|
33
|
+
# payment yang mengkredit order ini (untuk membedakan event ganda dari payment kedua).
|
|
34
|
+
credited_by: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Store(Protocol):
|
|
38
|
+
"""Ganti dengan tabel DB. `credit_once` harus satu transaksi dengan UNIQUE(order_id) dan UNIQUE(payment_id)."""
|
|
39
|
+
|
|
40
|
+
def create_order(self, user_id: str, package_id: str) -> Order: ...
|
|
41
|
+
|
|
42
|
+
def attach_payment(self, order_id: str, payment_id: str) -> None: ...
|
|
43
|
+
|
|
44
|
+
def get_order(self, order_id: str) -> Order | None: ...
|
|
45
|
+
|
|
46
|
+
def credit_once(self, order_id: str, payment_id: str) -> bool:
|
|
47
|
+
"""True bila koin dikredit sekarang, False bila order itu sudah pernah dikredit."""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
def is_processed(self, event_id: str) -> bool:
|
|
51
|
+
"""Event sudah selesai diproses? (tabel `processed_events` dengan UNIQUE(event_id))."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
def mark_processed(self, event_id: str) -> None: ...
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class MemoryStore:
|
|
58
|
+
def __init__(self) -> None:
|
|
59
|
+
self.orders: dict[str, Order] = {}
|
|
60
|
+
self.coins: dict[str, int] = {}
|
|
61
|
+
self.processed_events: set[str] = set()
|
|
62
|
+
|
|
63
|
+
def create_order(self, user_id: str, package_id: str) -> Order:
|
|
64
|
+
order = Order(id=f"ord_{uuid.uuid4()}", user_id=user_id, **PACKAGES[package_id]) # type: ignore[arg-type]
|
|
65
|
+
self.orders[order.id] = order
|
|
66
|
+
return order
|
|
67
|
+
|
|
68
|
+
def attach_payment(self, order_id: str, payment_id: str) -> None:
|
|
69
|
+
order = self.orders.get(order_id)
|
|
70
|
+
if order:
|
|
71
|
+
order.payment_id = payment_id
|
|
72
|
+
|
|
73
|
+
def get_order(self, order_id: str) -> Order | None:
|
|
74
|
+
return self.orders.get(order_id)
|
|
75
|
+
|
|
76
|
+
def credit_once(self, order_id: str, payment_id: str) -> bool:
|
|
77
|
+
order = self.orders.get(order_id)
|
|
78
|
+
if order is None or order.status == "credited":
|
|
79
|
+
return False
|
|
80
|
+
order.status = "credited"
|
|
81
|
+
order.credited_by = payment_id
|
|
82
|
+
self.coins[order.user_id] = self.coins.get(order.user_id, 0) + order.coins
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
def is_processed(self, event_id: str) -> bool:
|
|
86
|
+
return event_id in self.processed_events
|
|
87
|
+
|
|
88
|
+
def mark_processed(self, event_id: str) -> None:
|
|
89
|
+
self.processed_events.add(event_id)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def default_on_unmatched(payment: FQrisObject, reason: str) -> None:
|
|
93
|
+
print(
|
|
94
|
+
f"[FQris] payment {payment.id} lunas tetapi tidak cocok ({reason}): rekonsiliasi manual",
|
|
95
|
+
file=sys.stderr,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Topup:
|
|
100
|
+
"""`on_unmatched(payment, reason)`: reason `order_not_found` | `amount_mismatch`.
|
|
101
|
+
|
|
102
|
+
Uang sudah masuk tetapi order tidak ditemukan atau nominalnya beda. WAJIB diberi alarm (Sentry, Slack,
|
|
103
|
+
tiket rekonsiliasi) dan ditindaklanjuti manual; jangan hanya dicatat. Bawaan: cetak ke stderr.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
fqris: Client,
|
|
109
|
+
store: Store,
|
|
110
|
+
webhook_secret: str,
|
|
111
|
+
on_unmatched: Callable[[FQrisObject, str], None] | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
self.fqris = fqris
|
|
114
|
+
self.store = store
|
|
115
|
+
self.webhook_secret = webhook_secret
|
|
116
|
+
self.on_unmatched = on_unmatched or default_on_unmatched
|
|
117
|
+
|
|
118
|
+
def start(self, user_id: str, package_id: str) -> dict[str, str]:
|
|
119
|
+
"""Dipanggil aplikasi Android lewat backend-mu. Kembalikan `checkout_url` untuk dibuka di Custom Tab."""
|
|
120
|
+
order = self.store.create_order(user_id, package_id)
|
|
121
|
+
payment = self.fqris.payments.create(
|
|
122
|
+
amount=order.amount,
|
|
123
|
+
reference_id=f"TOPUP-{order.id}", # unik per payment: untuk mencoba lagi, buat order dan reference_id baru
|
|
124
|
+
metadata={"user_id": user_id, "order_id": order.id},
|
|
125
|
+
idempotency_key=f"TOPUP-{order.id}", # retry setelah timeout tidak membuat payment ganda
|
|
126
|
+
)
|
|
127
|
+
self.store.attach_payment(order.id, payment.id)
|
|
128
|
+
return {"order_id": order.id, "checkout_url": payment.checkout_url}
|
|
129
|
+
|
|
130
|
+
def handle_webhook(self, raw_body: bytes | str, sig_header: str | None) -> int:
|
|
131
|
+
"""`raw_body` HARUS body mentah, bukan hasil `request.json`. Mengembalikan kode HTTP."""
|
|
132
|
+
try:
|
|
133
|
+
event = Webhook.construct_event(raw_body, sig_header, self.webhook_secret)
|
|
134
|
+
except SignatureVerificationError:
|
|
135
|
+
return 400
|
|
136
|
+
if event.type != "payment.paid":
|
|
137
|
+
return 200
|
|
138
|
+
# Event yang sama bisa dikirim ulang (at-least-once): sudah selesai diproses -> balas 200 tanpa kerja apa pun.
|
|
139
|
+
if self.store.is_processed(event.id):
|
|
140
|
+
return 200
|
|
141
|
+
|
|
142
|
+
# Ambil ulang dari API: hanya status `paid` yang lolos (refunded, dll melempar PaymentNotPaidError).
|
|
143
|
+
try:
|
|
144
|
+
payment = self.fqris.payments.retrieve_and_assert_paid(
|
|
145
|
+
str(event.data.object.id)
|
|
146
|
+
)
|
|
147
|
+
except PaymentNotPaidError:
|
|
148
|
+
return 200
|
|
149
|
+
# Gangguan jaringan/5xx (FQrisError) dibiarkan lolos: framework membalas 500 supaya FQris mengirim ulang.
|
|
150
|
+
|
|
151
|
+
order = self.store.get_order(
|
|
152
|
+
(payment.get("metadata") or {}).get("order_id", "")
|
|
153
|
+
)
|
|
154
|
+
if order is None or payment.base_amount != order.amount:
|
|
155
|
+
# Uang sudah masuk tetapi tidak bisa dikaitkan ke order: alarm dulu, tetap balas 200 (retry tidak membantu).
|
|
156
|
+
self.on_unmatched(
|
|
157
|
+
payment, "order_not_found" if order is None else "amount_mismatch"
|
|
158
|
+
)
|
|
159
|
+
self.store.mark_processed(event.id)
|
|
160
|
+
return 200
|
|
161
|
+
|
|
162
|
+
# paid_late dan paid_after_cancel (payment dibatalkan lalu dibayar) diperlakukan sama dengan paid: di sini
|
|
163
|
+
# dikredit. Kebijakan lain, mis. refund bila ordermu sudah ditandai gagal, ditaruh di cabang ini.
|
|
164
|
+
if self.store.credit_once(order.id, payment.id):
|
|
165
|
+
self.store.mark_processed(event.id)
|
|
166
|
+
return 200
|
|
167
|
+
|
|
168
|
+
# Order sudah dikredit dari payment lain (pembeli membayar payment lama dan baru): kembalikan uang yang kedua.
|
|
169
|
+
credited = self.store.get_order(order.id)
|
|
170
|
+
if credited is None or credited.credited_by != payment.id:
|
|
171
|
+
self.fqris.refunds.create(
|
|
172
|
+
payment.id,
|
|
173
|
+
amount=payment.final_amount,
|
|
174
|
+
reason="duplicate",
|
|
175
|
+
note=f"Order {order.id} sudah dikredit",
|
|
176
|
+
idempotency_key=f"refund-{payment.id}",
|
|
177
|
+
)
|
|
178
|
+
self.store.mark_processed(event.id)
|
|
179
|
+
return 200
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# Pasangan dengan Flask:
|
|
183
|
+
#
|
|
184
|
+
# topup = Topup(fqris.Client(os.environ["FQRIS_KEY"]), store, os.environ["WHSEC"])
|
|
185
|
+
#
|
|
186
|
+
# @app.post("/topup")
|
|
187
|
+
# def start():
|
|
188
|
+
# return topup.start(current_user.id, "koin-200")
|
|
189
|
+
#
|
|
190
|
+
# @app.post("/webhooks/fqris")
|
|
191
|
+
# def webhook():
|
|
192
|
+
# code = topup.handle_webhook(request.get_data(), request.headers.get("FQris-Signature"))
|
|
193
|
+
# return "", code
|