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 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
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.