fqris 0.1.0__py3-none-any.whl
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/__init__.py +19 -0
- fqris/_client.py +48 -0
- fqris/_errors.py +44 -0
- fqris/_http.py +198 -0
- fqris/_object.py +32 -0
- fqris/_pagination.py +18 -0
- fqris/_transport.py +84 -0
- fqris/_webhook.py +81 -0
- fqris/py.typed +0 -0
- fqris/resources/__init__.py +0 -0
- fqris/resources/_base.py +25 -0
- fqris/resources/customers.py +41 -0
- fqris/resources/events.py +37 -0
- fqris/resources/invoices.py +75 -0
- fqris/resources/payment_links.py +50 -0
- fqris/resources/payments.py +67 -0
- fqris/resources/refunds.py +36 -0
- fqris/resources/sandbox.py +32 -0
- fqris/resources/webhook_endpoints.py +59 -0
- fqris-0.1.0.dist-info/METADATA +230 -0
- fqris-0.1.0.dist-info/RECORD +23 -0
- fqris-0.1.0.dist-info/WHEEL +4 -0
- fqris-0.1.0.dist-info/licenses/LICENSE +21 -0
fqris/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from ._client import Client
|
|
2
|
+
from ._errors import FQrisError, PaymentNotPaidError, SignatureVerificationError
|
|
3
|
+
from ._http import VERSION as __version__
|
|
4
|
+
from ._object import FQrisObject
|
|
5
|
+
from ._transport import HttpClient, HttpResponse, TransportError
|
|
6
|
+
from ._webhook import Webhook
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Client",
|
|
10
|
+
"FQrisError",
|
|
11
|
+
"FQrisObject",
|
|
12
|
+
"HttpClient",
|
|
13
|
+
"HttpResponse",
|
|
14
|
+
"PaymentNotPaidError",
|
|
15
|
+
"SignatureVerificationError",
|
|
16
|
+
"TransportError",
|
|
17
|
+
"Webhook",
|
|
18
|
+
"__version__",
|
|
19
|
+
]
|
fqris/_client.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from ._http import Http
|
|
7
|
+
from ._transport import HttpClient, UrllibClient
|
|
8
|
+
from .resources.customers import Customers
|
|
9
|
+
from .resources.events import Events
|
|
10
|
+
from .resources.invoices import Invoices
|
|
11
|
+
from .resources.payment_links import PaymentLinks
|
|
12
|
+
from .resources.payments import Payments
|
|
13
|
+
from .resources.refunds import Refunds
|
|
14
|
+
from .resources.sandbox import Sandbox
|
|
15
|
+
from .resources.webhook_endpoints import WebhookEndpoints
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Client:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
api_key: str,
|
|
22
|
+
*,
|
|
23
|
+
base_url: str = "https://api.fqris.id",
|
|
24
|
+
max_retries: int = 2,
|
|
25
|
+
timeout: float = 30.0,
|
|
26
|
+
http_client: HttpClient | None = None,
|
|
27
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
28
|
+
) -> None:
|
|
29
|
+
if not isinstance(api_key, str) or not api_key.startswith(
|
|
30
|
+
("fq_live_", "fq_test_")
|
|
31
|
+
):
|
|
32
|
+
raise ValueError("api_key harus diawali fq_live_ atau fq_test_.")
|
|
33
|
+
self._http = Http(
|
|
34
|
+
api_key,
|
|
35
|
+
base_url=base_url,
|
|
36
|
+
max_retries=max_retries,
|
|
37
|
+
timeout=timeout,
|
|
38
|
+
client=http_client or UrllibClient(),
|
|
39
|
+
sleep=sleep,
|
|
40
|
+
)
|
|
41
|
+
self.payments = Payments(self._http)
|
|
42
|
+
self.refunds = Refunds(self._http)
|
|
43
|
+
self.sandbox = Sandbox(self._http)
|
|
44
|
+
self.customers = Customers(self._http)
|
|
45
|
+
self.payment_links = PaymentLinks(self._http)
|
|
46
|
+
self.invoices = Invoices(self._http)
|
|
47
|
+
self.webhook_endpoints = WebhookEndpoints(self._http)
|
|
48
|
+
self.events = Events(self._http)
|
fqris/_errors.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
|
|
5
|
+
from ._object import FQrisObject
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FQrisError(Exception):
|
|
9
|
+
"""Error dari API FQris, atau dari SDK sendiri (status 0 untuk TIMEOUT/NETWORK_ERROR)."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
message: str,
|
|
14
|
+
*,
|
|
15
|
+
status: int,
|
|
16
|
+
type: str,
|
|
17
|
+
code: str,
|
|
18
|
+
param: str | None = None,
|
|
19
|
+
request_id: str | None = None,
|
|
20
|
+
doc_url: str | None = None,
|
|
21
|
+
headers: Mapping[str, str] | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.status = status
|
|
25
|
+
self.type = type
|
|
26
|
+
self.code = code
|
|
27
|
+
self.param = param
|
|
28
|
+
self.request_id = request_id
|
|
29
|
+
self.doc_url = doc_url
|
|
30
|
+
self.headers: Mapping[str, str] = headers or {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PaymentNotPaidError(Exception):
|
|
34
|
+
"""Payment belum/tidak lagi `paid` (mis. refunded, pending, expired)."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, payment: FQrisObject) -> None:
|
|
37
|
+
super().__init__(
|
|
38
|
+
f"Payment {payment.get('id')} belum lunas (status: {payment.get('status')})."
|
|
39
|
+
)
|
|
40
|
+
self.payment = payment
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SignatureVerificationError(Exception):
|
|
44
|
+
"""Webhook tidak bisa diverifikasi: header, timestamp, signature, atau body tidak valid."""
|
fqris/_http.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import platform
|
|
6
|
+
import random
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
from urllib.parse import urlencode
|
|
11
|
+
|
|
12
|
+
from ._errors import FQrisError
|
|
13
|
+
from ._object import FQrisObject
|
|
14
|
+
from ._transport import HttpClient, HttpResponse, TransportError
|
|
15
|
+
|
|
16
|
+
# Harus sama dengan pyproject.toml (dijaga oleh tests/test_version.py).
|
|
17
|
+
VERSION = "0.1.0"
|
|
18
|
+
|
|
19
|
+
MAX_RETRY_AFTER_S = 60.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _backoff(n: int) -> float:
|
|
23
|
+
return float(min(8.0, 0.5 * 2**n) * random.uniform(0.8, 1.2))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _scalar(v: Any) -> str:
|
|
27
|
+
if isinstance(v, bool):
|
|
28
|
+
return "true" if v else "false"
|
|
29
|
+
return str(v)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_query(query: Mapping[str, Any] | None) -> str:
|
|
33
|
+
if not query:
|
|
34
|
+
return ""
|
|
35
|
+
pairs: list[tuple[str, str]] = []
|
|
36
|
+
for k, v in query.items():
|
|
37
|
+
if v is None:
|
|
38
|
+
continue
|
|
39
|
+
if isinstance(v, (list, tuple)):
|
|
40
|
+
pairs += [(f"{k}[]", _scalar(i)) for i in v if i is not None]
|
|
41
|
+
elif isinstance(v, Mapping):
|
|
42
|
+
pairs += [
|
|
43
|
+
(f"{k}[{sk}]", _scalar(sv)) for sk, sv in v.items() if sv is not None
|
|
44
|
+
]
|
|
45
|
+
else:
|
|
46
|
+
pairs.append((k, _scalar(v)))
|
|
47
|
+
return f"?{urlencode(pairs)}" if pairs else ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _to_error(res: HttpResponse) -> FQrisError:
|
|
51
|
+
header_rid = res.headers.get("x-request-id")
|
|
52
|
+
try:
|
|
53
|
+
parsed = json.loads(res.body)
|
|
54
|
+
except ValueError:
|
|
55
|
+
parsed = None
|
|
56
|
+
e = parsed.get("error") if isinstance(parsed, dict) else None
|
|
57
|
+
if isinstance(e, dict):
|
|
58
|
+
|
|
59
|
+
def s(key: str) -> str | None:
|
|
60
|
+
v = e.get(key)
|
|
61
|
+
return v if isinstance(v, str) else None
|
|
62
|
+
|
|
63
|
+
return FQrisError(
|
|
64
|
+
str(e.get("message") or f"HTTP {res.status}"),
|
|
65
|
+
status=res.status,
|
|
66
|
+
type=str(e.get("type") or "api_error"),
|
|
67
|
+
code=str(e.get("code") or "UNKNOWN_ERROR"),
|
|
68
|
+
param=s("param"),
|
|
69
|
+
request_id=s("request_id") or header_rid,
|
|
70
|
+
doc_url=s("doc_url"),
|
|
71
|
+
headers=res.headers,
|
|
72
|
+
)
|
|
73
|
+
return FQrisError(
|
|
74
|
+
f"HTTP {res.status}: respons bukan JSON error FQris",
|
|
75
|
+
status=res.status,
|
|
76
|
+
type="api_error",
|
|
77
|
+
code="INTERNAL_ERROR" if res.status >= 500 else "INVALID_RESPONSE",
|
|
78
|
+
request_id=header_rid,
|
|
79
|
+
headers=res.headers,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Http:
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
api_key: str,
|
|
87
|
+
*,
|
|
88
|
+
base_url: str,
|
|
89
|
+
max_retries: int,
|
|
90
|
+
timeout: float,
|
|
91
|
+
client: HttpClient,
|
|
92
|
+
sleep: Callable[[float], None],
|
|
93
|
+
) -> None:
|
|
94
|
+
self._api_key = api_key
|
|
95
|
+
self._base_url = base_url.rstrip("/")
|
|
96
|
+
self._max_retries = max_retries
|
|
97
|
+
self._timeout = timeout
|
|
98
|
+
self._client = client
|
|
99
|
+
self._sleep = sleep
|
|
100
|
+
|
|
101
|
+
def request(
|
|
102
|
+
self,
|
|
103
|
+
method: str,
|
|
104
|
+
path: str,
|
|
105
|
+
body: Mapping[str, Any] | None = None,
|
|
106
|
+
query: Mapping[str, Any] | None = None,
|
|
107
|
+
*,
|
|
108
|
+
idempotency_key: str | None = None,
|
|
109
|
+
request_id: str | None = None,
|
|
110
|
+
) -> FQrisObject | None:
|
|
111
|
+
# POST tanpa field tetap mengirim `{}`; GET/DELETE tidak mengirim body.
|
|
112
|
+
if method == "POST" and body is None:
|
|
113
|
+
body = {}
|
|
114
|
+
res = self._send(method, path, body, query, idempotency_key, request_id)
|
|
115
|
+
if not res.body:
|
|
116
|
+
return None
|
|
117
|
+
try:
|
|
118
|
+
parsed = json.loads(res.body)
|
|
119
|
+
except ValueError:
|
|
120
|
+
parsed = None
|
|
121
|
+
if not isinstance(parsed, dict):
|
|
122
|
+
raise FQrisError(
|
|
123
|
+
"Respons sukses bukan objek JSON yang valid",
|
|
124
|
+
status=res.status,
|
|
125
|
+
type="api_error",
|
|
126
|
+
code="INVALID_RESPONSE",
|
|
127
|
+
request_id=res.headers.get("x-request-id"),
|
|
128
|
+
headers=res.headers,
|
|
129
|
+
)
|
|
130
|
+
return FQrisObject.construct_from(parsed)
|
|
131
|
+
|
|
132
|
+
def request_raw(self, path: str, query: Mapping[str, Any] | None = None) -> bytes:
|
|
133
|
+
return self._send("GET", path, None, query, None, None).body
|
|
134
|
+
|
|
135
|
+
def _send(
|
|
136
|
+
self,
|
|
137
|
+
method: str,
|
|
138
|
+
path: str,
|
|
139
|
+
body: Mapping[str, Any] | None,
|
|
140
|
+
query: Mapping[str, Any] | None,
|
|
141
|
+
idempotency_key: str | None,
|
|
142
|
+
request_id: str | None,
|
|
143
|
+
) -> HttpResponse:
|
|
144
|
+
url = f"{self._base_url}{path}{build_query(query)}"
|
|
145
|
+
headers = {
|
|
146
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
147
|
+
"Accept": "application/json",
|
|
148
|
+
"User-Agent": f"fqris-python/{VERSION} python/{platform.python_version()}",
|
|
149
|
+
}
|
|
150
|
+
# Kunci kosong/terlalu panjang (PRD 10.1) ditolak di sini; kunci kosong membuat retry bisa menggandakan payment.
|
|
151
|
+
if idempotency_key is not None and not 1 <= len(idempotency_key) <= 255:
|
|
152
|
+
raise ValueError("idempotency_key harus string 1-255 karakter.")
|
|
153
|
+
# Kunci dibuat SEKALI sebelum percobaan pertama: retry tidak boleh membuat payment ganda.
|
|
154
|
+
if method == "POST":
|
|
155
|
+
headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
|
|
156
|
+
if request_id:
|
|
157
|
+
headers["X-Request-Id"] = request_id
|
|
158
|
+
payload = None if body is None else json.dumps(body).encode()
|
|
159
|
+
# Fastify menolak Content-Type JSON tanpa body (400).
|
|
160
|
+
if payload is not None:
|
|
161
|
+
headers["Content-Type"] = "application/json"
|
|
162
|
+
|
|
163
|
+
attempt = 0
|
|
164
|
+
while True:
|
|
165
|
+
can_retry = attempt < self._max_retries
|
|
166
|
+
try:
|
|
167
|
+
res = self._client.send(method, url, headers, payload, self._timeout)
|
|
168
|
+
except TransportError as e:
|
|
169
|
+
if can_retry:
|
|
170
|
+
self._sleep(_backoff(attempt))
|
|
171
|
+
attempt += 1
|
|
172
|
+
continue
|
|
173
|
+
raise FQrisError(
|
|
174
|
+
f"Request melebihi {self._timeout} detik"
|
|
175
|
+
if e.timed_out
|
|
176
|
+
else f"Gagal menghubungi FQris: {e}",
|
|
177
|
+
status=0,
|
|
178
|
+
type="api_error",
|
|
179
|
+
code="TIMEOUT" if e.timed_out else "NETWORK_ERROR",
|
|
180
|
+
) from e
|
|
181
|
+
if 200 <= res.status < 300:
|
|
182
|
+
return res
|
|
183
|
+
if can_retry and (res.status == 429 or res.status >= 500):
|
|
184
|
+
delay = _backoff(attempt)
|
|
185
|
+
if res.status == 429:
|
|
186
|
+
try:
|
|
187
|
+
ra = float(res.headers.get("retry-after", "").strip())
|
|
188
|
+
except ValueError:
|
|
189
|
+
ra = math.nan
|
|
190
|
+
# Server minta menunggu terlalu lama: jangan menggantung, serahkan ke pemanggil.
|
|
191
|
+
if ra > MAX_RETRY_AFTER_S:
|
|
192
|
+
raise _to_error(res)
|
|
193
|
+
if math.isfinite(ra) and ra >= 0:
|
|
194
|
+
delay = ra
|
|
195
|
+
self._sleep(delay)
|
|
196
|
+
attempt += 1
|
|
197
|
+
continue
|
|
198
|
+
raise _to_error(res)
|
fqris/_object.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _wrap(v: Any) -> Any:
|
|
8
|
+
if isinstance(v, dict):
|
|
9
|
+
return FQrisObject.construct_from(v)
|
|
10
|
+
if isinstance(v, list):
|
|
11
|
+
return [_wrap(i) for i in v]
|
|
12
|
+
return v
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FQrisObject(dict[str, Any]):
|
|
16
|
+
"""dict dengan akses atribut bersarang (`p.metadata["user_id"]`, `event.data.object.id`).
|
|
17
|
+
|
|
18
|
+
Kunci yang bentrok dengan metode dict (`items`, `keys`, `values`, ...) dibaca lewat `obj["items"]`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def construct_from(cls, values: Mapping[str, Any]) -> FQrisObject:
|
|
23
|
+
return cls({k: _wrap(v) for k, v in values.items()})
|
|
24
|
+
|
|
25
|
+
def __getattr__(self, name: str) -> Any:
|
|
26
|
+
# Dunder harus AttributeError agar pickle/copy tidak rusak.
|
|
27
|
+
if name.startswith("__"):
|
|
28
|
+
raise AttributeError(name)
|
|
29
|
+
try:
|
|
30
|
+
return self[name]
|
|
31
|
+
except KeyError:
|
|
32
|
+
raise AttributeError(name) from None
|
fqris/_pagination.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from ._object import FQrisObject
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def paginate(fetch_page: Callable[[str | None], FQrisObject]) -> Iterator[FQrisObject]:
|
|
10
|
+
"""Mengikuti `next_cursor` (dikirim sebagai `starting_after`) sampai `has_more` false."""
|
|
11
|
+
cursor: str | None = None
|
|
12
|
+
while True:
|
|
13
|
+
page = fetch_page(cursor)
|
|
14
|
+
yield from page.data
|
|
15
|
+
# next_cursor kosong walau has_more true: berhenti, jangan mengulang halaman yang sama selamanya.
|
|
16
|
+
if not page.get("has_more") or not page.get("next_cursor"):
|
|
17
|
+
return
|
|
18
|
+
cursor = page.next_cursor
|
fqris/_transport.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Lapisan transport: antarmuka HttpClient dan implementasi bawaan berbasis urllib."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.client
|
|
6
|
+
import socket
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from email.message import Message
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class HttpResponse:
|
|
17
|
+
status: int
|
|
18
|
+
headers: Mapping[str, str] # nama header huruf kecil
|
|
19
|
+
body: bytes
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TransportError(Exception):
|
|
23
|
+
"""Kegagalan jaringan (bukan respons HTTP); status non-2xx BUKAN TransportError."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, *, timed_out: bool) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.timed_out = timed_out
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HttpClient(Protocol):
|
|
31
|
+
def send(
|
|
32
|
+
self,
|
|
33
|
+
method: str,
|
|
34
|
+
url: str,
|
|
35
|
+
headers: Mapping[str, str],
|
|
36
|
+
body: bytes | None,
|
|
37
|
+
timeout: float,
|
|
38
|
+
) -> HttpResponse: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _lower(headers: Message[str, str]) -> Mapping[str, str]:
|
|
42
|
+
return {k.lower(): v for k, v in headers.items()}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
46
|
+
# Jangan ikuti 3xx: urllib menyalin Authorization dan Idempotency-Key ke target redirect.
|
|
47
|
+
def redirect_request(self, *args: Any, **kwargs: Any) -> None:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_OPENER = urllib.request.build_opener(_NoRedirect)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class UrllibClient:
|
|
55
|
+
"""Implementasi bawaan tanpa dependensi."""
|
|
56
|
+
|
|
57
|
+
def send(
|
|
58
|
+
self,
|
|
59
|
+
method: str,
|
|
60
|
+
url: str,
|
|
61
|
+
headers: Mapping[str, str],
|
|
62
|
+
body: bytes | None,
|
|
63
|
+
timeout: float,
|
|
64
|
+
) -> HttpResponse:
|
|
65
|
+
req = urllib.request.Request(
|
|
66
|
+
url, data=body, headers=dict(headers), method=method
|
|
67
|
+
)
|
|
68
|
+
try:
|
|
69
|
+
try:
|
|
70
|
+
with _OPENER.open(req, timeout=timeout) as r:
|
|
71
|
+
return HttpResponse(r.status, _lower(r.headers), r.read())
|
|
72
|
+
except urllib.error.HTTPError as e:
|
|
73
|
+
# Status non-2xx adalah respons, bukan kegagalan transport.
|
|
74
|
+
try:
|
|
75
|
+
return HttpResponse(e.code, _lower(e.headers), e.read())
|
|
76
|
+
finally:
|
|
77
|
+
e.close()
|
|
78
|
+
except urllib.error.URLError as e:
|
|
79
|
+
timed_out = isinstance(e.reason, (socket.timeout, TimeoutError))
|
|
80
|
+
raise TransportError(str(e.reason), timed_out=timed_out) from e
|
|
81
|
+
except (socket.timeout, TimeoutError) as e:
|
|
82
|
+
raise TransportError(str(e) or "timeout", timed_out=True) from e
|
|
83
|
+
except (OSError, http.client.HTTPException) as e:
|
|
84
|
+
raise TransportError(str(e), timed_out=False) from e
|
fqris/_webhook.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ._errors import SignatureVerificationError
|
|
12
|
+
from ._object import FQrisObject
|
|
13
|
+
|
|
14
|
+
_HEX = re.compile(r"[0-9a-fA-F]+")
|
|
15
|
+
_DIGITS = re.compile(r"[0-9]+")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parse_header(header: Any) -> tuple[int, list[str]] | None:
|
|
19
|
+
if not isinstance(header, str):
|
|
20
|
+
return None
|
|
21
|
+
t: int | None = None
|
|
22
|
+
v1: list[str] = []
|
|
23
|
+
for part in header.split(","):
|
|
24
|
+
k, _, v = part.partition("=")
|
|
25
|
+
k, v = k.strip(), v.strip()
|
|
26
|
+
if k == "t" and _DIGITS.fullmatch(v):
|
|
27
|
+
t = int(v)
|
|
28
|
+
elif k == "v1" and _HEX.fullmatch(v):
|
|
29
|
+
v1.append(v.lower())
|
|
30
|
+
return None if t is None or not v1 else (t, v1)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Webhook:
|
|
34
|
+
"""Verifikasi header `FQris-Signature` lalu parse body menjadi event."""
|
|
35
|
+
|
|
36
|
+
DEFAULT_TOLERANCE = 300
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def construct_event(
|
|
40
|
+
payload: bytes | str,
|
|
41
|
+
sig_header: str | None,
|
|
42
|
+
secret: str,
|
|
43
|
+
tolerance: int = DEFAULT_TOLERANCE,
|
|
44
|
+
now: int | None = None,
|
|
45
|
+
) -> FQrisObject:
|
|
46
|
+
if not isinstance(payload, (bytes, bytearray, str)):
|
|
47
|
+
raise SignatureVerificationError(
|
|
48
|
+
"Payload webhook harus berupa raw body (str atau bytes), bukan hasil parse "
|
|
49
|
+
"(dict). Pakai request.body (Django) atau request.get_data() (Flask), "
|
|
50
|
+
"bukan request.json."
|
|
51
|
+
)
|
|
52
|
+
if not isinstance(secret, str) or secret.strip() == "":
|
|
53
|
+
raise SignatureVerificationError(
|
|
54
|
+
"Secret webhook kosong. Isi FQRIS webhook secret dari dashboard."
|
|
55
|
+
)
|
|
56
|
+
parsed = _parse_header(sig_header)
|
|
57
|
+
if parsed is None:
|
|
58
|
+
raise SignatureVerificationError("Header FQris-Signature hilang atau formatnya salah.")
|
|
59
|
+
t, v1 = parsed
|
|
60
|
+
|
|
61
|
+
current = int(time.time()) if now is None else now
|
|
62
|
+
# isfinite menolak inf dan NaN (inf mematikan proteksi replay), seperti SDK Node.
|
|
63
|
+
if not (math.isfinite(tolerance) and tolerance >= 0) or not abs(current - t) <= tolerance:
|
|
64
|
+
raise SignatureVerificationError(
|
|
65
|
+
"Timestamp signature di luar batas toleransi, atau tolerance tidak valid."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
body = payload.encode("utf-8") if isinstance(payload, str) else bytes(payload)
|
|
69
|
+
expected = hmac.new(secret.encode("utf-8"), f"{t}.".encode() + body, hashlib.sha256)
|
|
70
|
+
digest = expected.hexdigest()
|
|
71
|
+
if not any(hmac.compare_digest(c, digest) for c in v1):
|
|
72
|
+
raise SignatureVerificationError("Signature webhook tidak cocok.")
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
# errors="replace": signature sudah dicek atas byte mentah, sama seperti SDK Node.
|
|
76
|
+
data = json.loads(body.decode("utf-8", errors="replace"))
|
|
77
|
+
except ValueError:
|
|
78
|
+
raise SignatureVerificationError("Body webhook bukan JSON yang valid.") from None
|
|
79
|
+
if not isinstance(data, dict):
|
|
80
|
+
raise SignatureVerificationError("Body webhook harus berupa objek JSON.")
|
|
81
|
+
return FQrisObject.construct_from(data)
|
fqris/py.typed
ADDED
|
File without changes
|
|
File without changes
|
fqris/resources/_base.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
from .._http import Http
|
|
6
|
+
from .._object import FQrisObject
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def seg(id: str) -> str:
|
|
10
|
+
"""Satu segmen path dari id masukan pemanggil: di-encode, dan tolak yang bisa membelokkan request."""
|
|
11
|
+
if not isinstance(id, str) or id in ("", ".", ".."):
|
|
12
|
+
raise ValueError(f"ID tidak valid: {id!r}")
|
|
13
|
+
return quote(id, safe="")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def need(res: FQrisObject | None) -> FQrisObject:
|
|
17
|
+
# Endpoint ini selalu membalas objek; body kosong (204) berarti server menyimpang dari kontrak.
|
|
18
|
+
if res is None:
|
|
19
|
+
raise ValueError("Respons kosong dari API.")
|
|
20
|
+
return res
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Resource:
|
|
24
|
+
def __init__(self, http: Http) -> None:
|
|
25
|
+
self._http = http
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .._object import FQrisObject
|
|
7
|
+
from .._pagination import paginate
|
|
8
|
+
from ._base import Resource, need, seg
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Customers(Resource):
|
|
12
|
+
def create(
|
|
13
|
+
self,
|
|
14
|
+
*,
|
|
15
|
+
idempotency_key: str | None = None,
|
|
16
|
+
request_id: str | None = None,
|
|
17
|
+
**params: Any,
|
|
18
|
+
) -> FQrisObject:
|
|
19
|
+
return need(
|
|
20
|
+
self._http.request(
|
|
21
|
+
"POST",
|
|
22
|
+
"/v1/customers",
|
|
23
|
+
params,
|
|
24
|
+
idempotency_key=idempotency_key,
|
|
25
|
+
request_id=request_id,
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def retrieve(self, id: str) -> FQrisObject:
|
|
30
|
+
return need(self._http.request("GET", f"/v1/customers/{seg(id)}"))
|
|
31
|
+
|
|
32
|
+
def update(self, id: str, **params: Any) -> FQrisObject:
|
|
33
|
+
return need(self._http.request("PATCH", f"/v1/customers/{seg(id)}", params))
|
|
34
|
+
|
|
35
|
+
def list(self, **params: Any) -> FQrisObject:
|
|
36
|
+
return need(self._http.request("GET", "/v1/customers", query=params))
|
|
37
|
+
|
|
38
|
+
def list_all(self, **params: Any) -> Iterator[FQrisObject]:
|
|
39
|
+
return paginate(
|
|
40
|
+
lambda cursor: self.list(**{**params, "starting_after": cursor})
|
|
41
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .._object import FQrisObject
|
|
7
|
+
from .._pagination import paginate
|
|
8
|
+
from ._base import Resource, need, seg
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Events(Resource):
|
|
12
|
+
def retrieve(self, id: str) -> FQrisObject:
|
|
13
|
+
return need(self._http.request("GET", f"/v1/events/{seg(id)}"))
|
|
14
|
+
|
|
15
|
+
def list(self, **params: Any) -> FQrisObject:
|
|
16
|
+
return need(self._http.request("GET", "/v1/events", query=params))
|
|
17
|
+
|
|
18
|
+
def list_all(self, **params: Any) -> Iterator[FQrisObject]:
|
|
19
|
+
return paginate(
|
|
20
|
+
lambda cursor: self.list(**{**params, "starting_after": cursor})
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def resend(
|
|
24
|
+
self,
|
|
25
|
+
id: str,
|
|
26
|
+
*,
|
|
27
|
+
idempotency_key: str | None = None,
|
|
28
|
+
**params: Any,
|
|
29
|
+
) -> FQrisObject:
|
|
30
|
+
return need(
|
|
31
|
+
self._http.request(
|
|
32
|
+
"POST",
|
|
33
|
+
f"/v1/events/{seg(id)}/resend",
|
|
34
|
+
params,
|
|
35
|
+
idempotency_key=idempotency_key,
|
|
36
|
+
)
|
|
37
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .._object import FQrisObject
|
|
7
|
+
from .._pagination import paginate
|
|
8
|
+
from ._base import Resource, need, seg
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Invoices(Resource):
|
|
12
|
+
def create(
|
|
13
|
+
self,
|
|
14
|
+
*,
|
|
15
|
+
idempotency_key: str | None = None,
|
|
16
|
+
request_id: str | None = None,
|
|
17
|
+
**params: Any,
|
|
18
|
+
) -> FQrisObject:
|
|
19
|
+
return need(
|
|
20
|
+
self._http.request(
|
|
21
|
+
"POST",
|
|
22
|
+
"/v1/invoices",
|
|
23
|
+
params,
|
|
24
|
+
idempotency_key=idempotency_key,
|
|
25
|
+
request_id=request_id,
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def retrieve(self, id: str) -> FQrisObject:
|
|
30
|
+
return need(self._http.request("GET", f"/v1/invoices/{seg(id)}"))
|
|
31
|
+
|
|
32
|
+
def update(self, id: str, **params: Any) -> FQrisObject:
|
|
33
|
+
return need(self._http.request("PATCH", f"/v1/invoices/{seg(id)}", params))
|
|
34
|
+
|
|
35
|
+
def list(self, **params: Any) -> FQrisObject:
|
|
36
|
+
return need(self._http.request("GET", "/v1/invoices", query=params))
|
|
37
|
+
|
|
38
|
+
def list_all(self, **params: Any) -> Iterator[FQrisObject]:
|
|
39
|
+
return paginate(
|
|
40
|
+
lambda cursor: self.list(**{**params, "starting_after": cursor})
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def delete(self, id: str) -> FQrisObject | None:
|
|
44
|
+
"""Hapus draf; server membalas 204 tanpa body (hasil `None`)."""
|
|
45
|
+
return self._http.request("DELETE", f"/v1/invoices/{seg(id)}")
|
|
46
|
+
|
|
47
|
+
def pdf(self, id: str) -> bytes:
|
|
48
|
+
return self._http.request_raw(f"/v1/invoices/{seg(id)}/pdf")
|
|
49
|
+
|
|
50
|
+
def finalize(self, id: str, *, idempotency_key: str | None = None) -> FQrisObject:
|
|
51
|
+
return need(
|
|
52
|
+
self._http.request(
|
|
53
|
+
"POST",
|
|
54
|
+
f"/v1/invoices/{seg(id)}/finalize",
|
|
55
|
+
idempotency_key=idempotency_key,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def void(self, id: str, *, idempotency_key: str | None = None) -> FQrisObject:
|
|
60
|
+
return need(
|
|
61
|
+
self._http.request(
|
|
62
|
+
"POST",
|
|
63
|
+
f"/v1/invoices/{seg(id)}/void",
|
|
64
|
+
idempotency_key=idempotency_key,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def send(self, id: str, *, idempotency_key: str | None = None) -> FQrisObject:
|
|
69
|
+
return need(
|
|
70
|
+
self._http.request(
|
|
71
|
+
"POST",
|
|
72
|
+
f"/v1/invoices/{seg(id)}/send",
|
|
73
|
+
idempotency_key=idempotency_key,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .._object import FQrisObject
|
|
7
|
+
from .._pagination import paginate
|
|
8
|
+
from ._base import Resource, need, seg
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PaymentLinks(Resource):
|
|
12
|
+
def create(
|
|
13
|
+
self,
|
|
14
|
+
*,
|
|
15
|
+
idempotency_key: str | None = None,
|
|
16
|
+
request_id: str | None = None,
|
|
17
|
+
**params: Any,
|
|
18
|
+
) -> FQrisObject:
|
|
19
|
+
return need(
|
|
20
|
+
self._http.request(
|
|
21
|
+
"POST",
|
|
22
|
+
"/v1/payment-links",
|
|
23
|
+
params,
|
|
24
|
+
idempotency_key=idempotency_key,
|
|
25
|
+
request_id=request_id,
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def retrieve(self, id: str) -> FQrisObject:
|
|
30
|
+
return need(self._http.request("GET", f"/v1/payment-links/{seg(id)}"))
|
|
31
|
+
|
|
32
|
+
def update(self, id: str, **params: Any) -> FQrisObject:
|
|
33
|
+
return need(self._http.request("PATCH", f"/v1/payment-links/{seg(id)}", params))
|
|
34
|
+
|
|
35
|
+
def list(self, **params: Any) -> FQrisObject:
|
|
36
|
+
return need(self._http.request("GET", "/v1/payment-links", query=params))
|
|
37
|
+
|
|
38
|
+
def list_all(self, **params: Any) -> Iterator[FQrisObject]:
|
|
39
|
+
return paginate(
|
|
40
|
+
lambda cursor: self.list(**{**params, "starting_after": cursor})
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def archive(self, id: str, *, idempotency_key: str | None = None) -> FQrisObject:
|
|
44
|
+
return need(
|
|
45
|
+
self._http.request(
|
|
46
|
+
"POST",
|
|
47
|
+
f"/v1/payment-links/{seg(id)}/archive",
|
|
48
|
+
idempotency_key=idempotency_key,
|
|
49
|
+
)
|
|
50
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator, Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .._errors import PaymentNotPaidError
|
|
7
|
+
from .._object import FQrisObject
|
|
8
|
+
from .._pagination import paginate
|
|
9
|
+
from ._base import Resource, need, seg
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Payments(Resource):
|
|
13
|
+
def create(
|
|
14
|
+
self,
|
|
15
|
+
*,
|
|
16
|
+
idempotency_key: str | None = None,
|
|
17
|
+
request_id: str | None = None,
|
|
18
|
+
**params: Any,
|
|
19
|
+
) -> FQrisObject:
|
|
20
|
+
return need(
|
|
21
|
+
self._http.request(
|
|
22
|
+
"POST",
|
|
23
|
+
"/v1/payments",
|
|
24
|
+
params,
|
|
25
|
+
idempotency_key=idempotency_key,
|
|
26
|
+
request_id=request_id,
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def retrieve(self, id: str, *, expand: Sequence[str] | None = None) -> FQrisObject:
|
|
31
|
+
return need(
|
|
32
|
+
self._http.request(
|
|
33
|
+
"GET", f"/v1/payments/{seg(id)}", query={"expand": expand}
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def list(self, **params: Any) -> FQrisObject:
|
|
38
|
+
return need(self._http.request("GET", "/v1/payments", query=params))
|
|
39
|
+
|
|
40
|
+
def list_all(self, **params: Any) -> Iterator[FQrisObject]:
|
|
41
|
+
return paginate(
|
|
42
|
+
lambda cursor: self.list(**{**params, "starting_after": cursor})
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def cancel(self, id: str, *, idempotency_key: str | None = None) -> FQrisObject:
|
|
46
|
+
return need(
|
|
47
|
+
self._http.request(
|
|
48
|
+
"POST",
|
|
49
|
+
f"/v1/payments/{seg(id)}/cancel",
|
|
50
|
+
idempotency_key=idempotency_key,
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def events(self, id: str) -> FQrisObject:
|
|
55
|
+
return need(self._http.request("GET", f"/v1/payments/{seg(id)}/events"))
|
|
56
|
+
|
|
57
|
+
def qr(self, id: str, format: str = "png") -> bytes:
|
|
58
|
+
if format not in ("png", "svg"):
|
|
59
|
+
raise ValueError("format harus 'png' atau 'svg'.")
|
|
60
|
+
return self._http.request_raw(f"/v1/payments/{seg(id)}/qr.{format}")
|
|
61
|
+
|
|
62
|
+
def retrieve_and_assert_paid(self, id: str) -> FQrisObject:
|
|
63
|
+
"""Lolos hanya bila status `paid`; refunded/partially_refunded/dll melempar agar tidak dikredit ulang."""
|
|
64
|
+
payment = self.retrieve(id)
|
|
65
|
+
if payment.get("status") != "paid":
|
|
66
|
+
raise PaymentNotPaidError(payment)
|
|
67
|
+
return payment
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .._object import FQrisObject
|
|
6
|
+
from ._base import Resource, need, seg
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Refunds(Resource):
|
|
10
|
+
def create(
|
|
11
|
+
self,
|
|
12
|
+
payment_id: str,
|
|
13
|
+
*,
|
|
14
|
+
idempotency_key: str | None = None,
|
|
15
|
+
**params: Any,
|
|
16
|
+
) -> FQrisObject:
|
|
17
|
+
return need(
|
|
18
|
+
self._http.request(
|
|
19
|
+
"POST",
|
|
20
|
+
f"/v1/payments/{seg(payment_id)}/refunds",
|
|
21
|
+
params,
|
|
22
|
+
idempotency_key=idempotency_key,
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def list(self, payment_id: str) -> FQrisObject:
|
|
27
|
+
return need(
|
|
28
|
+
self._http.request("GET", f"/v1/payments/{seg(payment_id)}/refunds")
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def void(self, id: str, *, idempotency_key: str | None = None) -> FQrisObject:
|
|
32
|
+
return need(
|
|
33
|
+
self._http.request(
|
|
34
|
+
"POST", f"/v1/refunds/{seg(id)}/void", idempotency_key=idempotency_key
|
|
35
|
+
)
|
|
36
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .._object import FQrisObject
|
|
6
|
+
from ._base import Resource, need, seg
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Sandbox(Resource):
|
|
10
|
+
def simulate_payment(
|
|
11
|
+
self, id: str, *, idempotency_key: str | None = None, **params: Any
|
|
12
|
+
) -> FQrisObject:
|
|
13
|
+
return need(
|
|
14
|
+
self._http.request(
|
|
15
|
+
"POST",
|
|
16
|
+
f"/v1/sandbox/payments/{seg(id)}/simulate",
|
|
17
|
+
params,
|
|
18
|
+
idempotency_key=idempotency_key,
|
|
19
|
+
)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def simulate_notification(
|
|
23
|
+
self, *, idempotency_key: str | None = None, **params: Any
|
|
24
|
+
) -> FQrisObject:
|
|
25
|
+
return need(
|
|
26
|
+
self._http.request(
|
|
27
|
+
"POST",
|
|
28
|
+
"/v1/sandbox/devices/simulate-notification",
|
|
29
|
+
params,
|
|
30
|
+
idempotency_key=idempotency_key,
|
|
31
|
+
)
|
|
32
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .._object import FQrisObject
|
|
6
|
+
from ._base import Resource, need, seg
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WebhookEndpoints(Resource):
|
|
10
|
+
def create(
|
|
11
|
+
self,
|
|
12
|
+
*,
|
|
13
|
+
idempotency_key: str | None = None,
|
|
14
|
+
request_id: str | None = None,
|
|
15
|
+
**params: Any,
|
|
16
|
+
) -> FQrisObject:
|
|
17
|
+
return need(
|
|
18
|
+
self._http.request(
|
|
19
|
+
"POST",
|
|
20
|
+
"/v1/webhook-endpoints",
|
|
21
|
+
params,
|
|
22
|
+
idempotency_key=idempotency_key,
|
|
23
|
+
request_id=request_id,
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def retrieve(self, id: str) -> FQrisObject:
|
|
28
|
+
return need(self._http.request("GET", f"/v1/webhook-endpoints/{seg(id)}"))
|
|
29
|
+
|
|
30
|
+
def update(self, id: str, **params: Any) -> FQrisObject:
|
|
31
|
+
return need(
|
|
32
|
+
self._http.request("PATCH", f"/v1/webhook-endpoints/{seg(id)}", params)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def delete(self, id: str) -> FQrisObject | None:
|
|
36
|
+
return self._http.request("DELETE", f"/v1/webhook-endpoints/{seg(id)}")
|
|
37
|
+
|
|
38
|
+
def list(self) -> FQrisObject:
|
|
39
|
+
return need(self._http.request("GET", "/v1/webhook-endpoints"))
|
|
40
|
+
|
|
41
|
+
def rotate_secret(
|
|
42
|
+
self, id: str, *, idempotency_key: str | None = None
|
|
43
|
+
) -> FQrisObject:
|
|
44
|
+
return need(
|
|
45
|
+
self._http.request(
|
|
46
|
+
"POST",
|
|
47
|
+
f"/v1/webhook-endpoints/{seg(id)}/rotate-secret",
|
|
48
|
+
idempotency_key=idempotency_key,
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def test(self, id: str, *, idempotency_key: str | None = None) -> FQrisObject:
|
|
53
|
+
return need(
|
|
54
|
+
self._http.request(
|
|
55
|
+
"POST",
|
|
56
|
+
f"/v1/webhook-endpoints/{seg(id)}/test",
|
|
57
|
+
idempotency_key=idempotency_key,
|
|
58
|
+
)
|
|
59
|
+
)
|
|
@@ -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
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
fqris/__init__.py,sha256=sSYGs7ORBSD4YIVae3MEzGtExR-NuaazNpwV38sCK_Q,500
|
|
2
|
+
fqris/_client.py,sha256=EpP2Z3TJKuEy5dEBTDWGvHk7zhwFiZJCuiNrrfzVPG0,1606
|
|
3
|
+
fqris/_errors.py,sha256=XZB2hdIuiR4B0JRNbHlmSmm0TPUtE9mREbWwJV4aPBg,1266
|
|
4
|
+
fqris/_http.py,sha256=NI_t54OEDUqwSoDWaU9HnFESMA4AhdZdB92evyCzItw,6842
|
|
5
|
+
fqris/_object.py,sha256=rgsVi_kH73IdaM1v6wKzVe7O4oRVfgxYFvYIjLseoVQ,972
|
|
6
|
+
fqris/_pagination.py,sha256=Ag5gOMuR-ylOzhD56eXhIOKia-ZYeXH_KvrVCVQMKW4,655
|
|
7
|
+
fqris/_transport.py,sha256=eGQFP4EyVzytYDm-gwz_NCJ-ByiLXFXcb6Pxn5OF0aQ,2609
|
|
8
|
+
fqris/_webhook.py,sha256=wYXuHu0WWgVseaEkBg_EAxeuMgsfvS3XArp7VFiUFr8,3083
|
|
9
|
+
fqris/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
fqris/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
fqris/resources/_base.py,sha256=d5_3vP8GOciiz0-rezmtjMQa_luj8EcGfbDpXsdORIE,727
|
|
12
|
+
fqris/resources/customers.py,sha256=P9s24t8z_V_9lSGpZwgCYgH1a2L_TklxJHVADxXEufg,1226
|
|
13
|
+
fqris/resources/events.py,sha256=NZt7QjkOAH3NIljY7Xj6mcMyMM544uNnYJha8xntnEc,1024
|
|
14
|
+
fqris/resources/invoices.py,sha256=_5xKNXwK9pPcTOQf0WnWYCizxPqdlF6u4HsYf2A9rC8,2380
|
|
15
|
+
fqris/resources/payment_links.py,sha256=D6f6o2uxlD5yUbk6LmZq40pkJLAvcwi8sZY49gCidnM,1538
|
|
16
|
+
fqris/resources/payments.py,sha256=Wpsr6Ur3B3md-VbzLstAcrEcIYn6qQ9hc9W3IJ8kb9Y,2227
|
|
17
|
+
fqris/resources/refunds.py,sha256=0xbB_OT2fzLO-Rj_Eg0_G6bqWYovSsLsGwe0Hy4klSA,961
|
|
18
|
+
fqris/resources/sandbox.py,sha256=zUGkaZDQyrGMtGzHngm8d_7HZFOliNGyhIhPztPoRcQ,874
|
|
19
|
+
fqris/resources/webhook_endpoints.py,sha256=xhYnGlpMcrGw4Y-5UWpSY90PPCBPqcWlpdkSipMO7DU,1757
|
|
20
|
+
fqris-0.1.0.dist-info/METADATA,sha256=mfS1ULOF3YiYNNlTop-2YTAGPGHIhTA_p3o9qJsASj0,9823
|
|
21
|
+
fqris-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
22
|
+
fqris-0.1.0.dist-info/licenses/LICENSE,sha256=WGxDAlAg88teidT71I2vRHQSdcbxsWYlVivAseIwpfY,1062
|
|
23
|
+
fqris-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|