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.
Files changed (41) hide show
  1. fqris-0.1.0/.gitignore +8 -0
  2. fqris-0.1.0/LICENSE +21 -0
  3. fqris-0.1.0/PKG-INFO +230 -0
  4. fqris-0.1.0/README.md +219 -0
  5. fqris-0.1.0/examples/topup.py +193 -0
  6. fqris-0.1.0/pyproject.toml +38 -0
  7. fqris-0.1.0/src/fqris/__init__.py +19 -0
  8. fqris-0.1.0/src/fqris/_client.py +48 -0
  9. fqris-0.1.0/src/fqris/_errors.py +44 -0
  10. fqris-0.1.0/src/fqris/_http.py +198 -0
  11. fqris-0.1.0/src/fqris/_object.py +32 -0
  12. fqris-0.1.0/src/fqris/_pagination.py +18 -0
  13. fqris-0.1.0/src/fqris/_transport.py +84 -0
  14. fqris-0.1.0/src/fqris/_webhook.py +81 -0
  15. fqris-0.1.0/src/fqris/py.typed +0 -0
  16. fqris-0.1.0/src/fqris/resources/__init__.py +0 -0
  17. fqris-0.1.0/src/fqris/resources/_base.py +25 -0
  18. fqris-0.1.0/src/fqris/resources/customers.py +41 -0
  19. fqris-0.1.0/src/fqris/resources/events.py +37 -0
  20. fqris-0.1.0/src/fqris/resources/invoices.py +75 -0
  21. fqris-0.1.0/src/fqris/resources/payment_links.py +50 -0
  22. fqris-0.1.0/src/fqris/resources/payments.py +67 -0
  23. fqris-0.1.0/src/fqris/resources/refunds.py +36 -0
  24. fqris-0.1.0/src/fqris/resources/sandbox.py +32 -0
  25. fqris-0.1.0/src/fqris/resources/webhook_endpoints.py +59 -0
  26. fqris-0.1.0/tests/conftest.py +26 -0
  27. fqris-0.1.0/tests/fake.py +52 -0
  28. fqris-0.1.0/tests/integration/test_integration.py +174 -0
  29. fqris-0.1.0/tests/test_docs_contract.py +50 -0
  30. fqris-0.1.0/tests/test_http.py +259 -0
  31. fqris-0.1.0/tests/test_object.py +64 -0
  32. fqris-0.1.0/tests/test_package.py +37 -0
  33. fqris-0.1.0/tests/test_pagination.py +19 -0
  34. fqris-0.1.0/tests/test_payments.py +141 -0
  35. fqris-0.1.0/tests/test_refunds.py +50 -0
  36. fqris-0.1.0/tests/test_resources.py +245 -0
  37. fqris-0.1.0/tests/test_routes_coverage.py +27 -0
  38. fqris-0.1.0/tests/test_topup_example.py +180 -0
  39. fqris-0.1.0/tests/test_urllib_client.py +147 -0
  40. fqris-0.1.0/tests/test_version.py +12 -0
  41. fqris-0.1.0/tests/test_webhook.py +121 -0
fqris-0.1.0/.gitignore ADDED
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ uv.lock
3
+ __pycache__/
4
+ *.egg-info/
5
+ dist/
6
+ .mypy_cache/
7
+ .pytest_cache/
8
+ .ruff_cache/
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