thunder-solution-sdk 1.0.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.
thunder_slip/client.py ADDED
@@ -0,0 +1,860 @@
1
+ """Resource clients with matching synchronous and asynchronous APIs."""
2
+
3
+ from __future__ import annotations
4
+ import asyncio
5
+ import os
6
+ import time
7
+ import queue
8
+ import threading
9
+ from dataclasses import replace
10
+ from pathlib import Path
11
+ from collections.abc import Iterator, AsyncIterator
12
+ from typing import Any, BinaryIO, TypeVar, cast
13
+ import httpx
14
+ from . import models as m
15
+ from ._codec import decode, encode, UNSET
16
+ from ._transport import Transport, AsyncTransport
17
+ from .files import image_input
18
+ from .errors import (
19
+ InputError,
20
+ BatchSubmitError,
21
+ JobWaitTimeoutError,
22
+ RequestTimeoutError,
23
+ rate_limit,
24
+ redact,
25
+ redact_header,
26
+ )
27
+
28
+ T = TypeVar("T", bound=m.Model)
29
+ FileInput = str | Path | bytes | BinaryIO
30
+
31
+
32
+ def _settings(api_key: str | None, v2: str | None, v1: str | None) -> tuple[str, str, str, str]:
33
+ key = api_key or os.environ.get("THUNDER_API_KEY")
34
+ if not key:
35
+ file = os.environ.get("THUNDER_API_KEY_FILE")
36
+ if file:
37
+ try:
38
+ key = Path(file).read_text().strip()
39
+ except OSError:
40
+ raise InputError("INVALID_ARGUMENT", "Cannot read API key file") from None
41
+ if not key:
42
+ raise InputError("INVALID_ARGUMENT", "API key is required")
43
+ return (
44
+ key,
45
+ v2 or os.environ.get("THUNDER_V2_BASE_URL") or "https://api.thunder.in.th/v2",
46
+ v1 or os.environ.get("THUNDER_V1_BASE_URL") or "https://api.thunder.in.th/v1",
47
+ "thunder-slip-python/1.0.0", # x-release-please-version
48
+ )
49
+
50
+
51
+ def _text(value: str, maximum: int) -> str:
52
+ if not isinstance(value, str) or not 1 <= len(value) <= maximum:
53
+ raise InputError("INVALID_ARGUMENT", "String argument has invalid length")
54
+ return value
55
+
56
+
57
+ def _id(value: int) -> str:
58
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
59
+ raise InputError("INVALID_ARGUMENT", "Account id must be positive")
60
+ return str(value)
61
+
62
+
63
+ def _form(value: Any) -> str:
64
+ if isinstance(value, bool):
65
+ return "true" if value else "false"
66
+ if isinstance(value, float):
67
+ return format(value, ".15g")
68
+ return str(value)
69
+
70
+
71
+ def _decode_response(model: type[T], response: httpx.Response, secret: str) -> T:
72
+ body = response.json()
73
+ data = body["data"]
74
+ result = (
75
+ cast(T, m.BankList(items=[decode(m.Bank, x) for x in data]))
76
+ if model is m.BankList
77
+ else decode(model, data)
78
+ )
79
+ headers = {k: redact_header(k, v, secret) for k, v in response.headers.items()}
80
+ result.meta = m.ResponseMeta(
81
+ status=response.status_code,
82
+ request_id=headers.get("x-request-id") or headers.get("cf-ray"),
83
+ message=redact(body["message"], secret) if isinstance(body.get("message"), str) else None,
84
+ rate_limit=rate_limit(headers),
85
+ headers=headers,
86
+ )
87
+ return result
88
+
89
+
90
+ class Resource:
91
+ """Internal resource base; transports remain owned by the parent client."""
92
+
93
+ def __init__(self, transport: Transport) -> None:
94
+ self._transport = transport
95
+
96
+ def _call(
97
+ self, model: type[T], method: str, path: str, *, safe: bool = False, **kwargs: Any
98
+ ) -> T:
99
+ response = self._transport.request(method, path, safe=safe, **kwargs)
100
+ return _decode_response(model, response, self._transport._key)
101
+
102
+
103
+ class Verify(Resource):
104
+ """Billed bank and wallet verification operations."""
105
+
106
+ def _verify(
107
+ self,
108
+ field: str,
109
+ value: str | FileInput,
110
+ options: m.VerifyOptions | None,
111
+ wallet: bool = False,
112
+ ) -> m.BankSlipResult | m.TrueWalletResult:
113
+ body = encode(options) if options else {}
114
+ path = "/verify/truewallet" if wallet else "/verify/bank"
115
+ model: type[m.TrueWalletResult] | type[m.BankSlipResult] = (
116
+ m.TrueWalletResult if wallet else m.BankSlipResult
117
+ )
118
+ if field == "image":
119
+ image = image_input(value, max_bytes=4194304)
120
+ return self._call(
121
+ model,
122
+ "POST",
123
+ path,
124
+ data={k: _form(v) for k, v in body.items()},
125
+ files={"image": (image.filename, image.data, image.content_type)},
126
+ )
127
+ body[field] = value
128
+ return self._call(model, "POST", path, json=body)
129
+
130
+ def bank_by_payload(
131
+ self, payload: str, options: m.VerifyOptions | None = None
132
+ ) -> m.BankSlipResult:
133
+ """Verify one bank slip using payload; may spend quota."""
134
+ _text(payload, 128)
135
+ return cast(m.BankSlipResult, self._verify("payload", payload, options, False))
136
+
137
+ def bank_by_image(
138
+ self, file: FileInput, options: m.VerifyOptions | None = None
139
+ ) -> m.BankSlipResult:
140
+ """Verify one bank slip using image; may spend quota."""
141
+ return cast(m.BankSlipResult, self._verify("image", file, options, False))
142
+
143
+ def bank_by_base64(
144
+ self, base64: str, options: m.VerifyOptions | None = None
145
+ ) -> m.BankSlipResult:
146
+ """Verify one bank slip using base64; may spend quota."""
147
+ return cast(m.BankSlipResult, self._verify("base64", base64, options, False))
148
+
149
+ def bank_by_url(self, url: str, options: m.VerifyOptions | None = None) -> m.BankSlipResult:
150
+ """Verify one bank slip using url; may spend quota."""
151
+ _text(url, 2048)
152
+ return cast(m.BankSlipResult, self._verify("url", url, options, False))
153
+
154
+ def truewallet_by_image(
155
+ self, file: FileInput, options: m.VerifyOptions | None = None
156
+ ) -> m.TrueWalletResult:
157
+ """Verify one wallet slip using image; may spend quota."""
158
+ return cast(m.TrueWalletResult, self._verify("image", file, options, True))
159
+
160
+ def truewallet_by_base64(
161
+ self, base64: str, options: m.VerifyOptions | None = None
162
+ ) -> m.TrueWalletResult:
163
+ """Verify one wallet slip using base64; may spend quota."""
164
+ return cast(m.TrueWalletResult, self._verify("base64", base64, options, True))
165
+
166
+ def truewallet_by_url(
167
+ self, url: str, options: m.VerifyOptions | None = None
168
+ ) -> m.TrueWalletResult:
169
+ """Verify one wallet slip using url; may spend quota."""
170
+ _text(url, 255)
171
+ return cast(m.TrueWalletResult, self._verify("url", url, options, True))
172
+
173
+
174
+ class Banks(Resource):
175
+ """Bank catalog."""
176
+
177
+ def list(self) -> m.BankList:
178
+ """Return supported banks and their verification choices."""
179
+ return self._call(m.BankList, "GET", "/banks", safe=True)
180
+
181
+
182
+ class BankAccounts(Resource):
183
+ """Receiving account management and lazy pagination."""
184
+
185
+ def _list(
186
+ self, params: m.ListBankAccountsParams | None, all_accounts: bool
187
+ ) -> m.BankAccountPage:
188
+ query = encode(params) if params else {}
189
+ if "page" in query and (type(query["page"]) is not int or query["page"] < 1):
190
+ raise InputError("INVALID_ARGUMENT", "page must be positive")
191
+ if "limit" in query and (type(query["limit"]) is not int or not 1 <= query["limit"] <= 100):
192
+ raise InputError("INVALID_ARGUMENT", "limit must be between 1 and 100")
193
+ return self._call(
194
+ m.BankAccountPage,
195
+ "GET",
196
+ "/bank-accounts" + ("/all" if all_accounts else ""),
197
+ safe=True,
198
+ params={k: _form(v) for k, v in query.items()},
199
+ )
200
+
201
+ def list(self, params: m.ListBankAccountsParams | None = None) -> m.BankAccountPage:
202
+ """Return one page linked to this branch."""
203
+ return self._list(params, False)
204
+
205
+ def list_all(self, params: m.ListBankAccountsParams | None = None) -> m.BankAccountPage:
206
+ """Return one page across the service."""
207
+ return self._list(params, True)
208
+
209
+ def get(self, id: int) -> m.BankAccount:
210
+ """Read one receiving account."""
211
+ return self._call(m.BankAccount, "GET", "/bank-accounts/" + _id(id), safe=True)
212
+
213
+ def create(self, params: m.CreateBankAccountParams) -> m.BankAccount:
214
+ """Create an account; ambiguous failures are never retried."""
215
+ return self._call(m.BankAccount, "POST", "/bank-accounts", json=encode(params))
216
+
217
+ def update(self, id: int, params: m.UpdateBankAccountParams) -> m.BankAccount:
218
+ """Update selected fields; explicit extra_verify=None clears that field."""
219
+ return self._call(m.BankAccount, "PATCH", "/bank-accounts/" + _id(id), json=encode(params))
220
+
221
+ def link(self, id: int) -> m.LinkResult:
222
+ """Idempotently link an account to this branch."""
223
+ return self._call(m.LinkResult, "POST", "/bank-accounts/" + _id(id) + "/link", safe=True)
224
+
225
+ def unlink(self, id: int) -> m.UnlinkResult:
226
+ """Unlink an account from this branch."""
227
+ return self._call(m.UnlinkResult, "DELETE", "/bank-accounts/" + _id(id) + "/link")
228
+
229
+ def _iterate(
230
+ self, params: m.ListBankAccountsParams | None, all_accounts: bool
231
+ ) -> Iterator[m.BankAccount]:
232
+ params = params or m.ListBankAccountsParams()
233
+ page = params.page if params.page is not UNSET else 1
234
+ limit = params.limit if params.limit is not UNSET else 100
235
+ while True:
236
+ response = self._list(replace(params, page=page, limit=limit), all_accounts)
237
+ for item in response.items:
238
+ yield item
239
+ if not response.items or page * limit >= response.total:
240
+ break
241
+ page += 1
242
+
243
+ def iterate(self, params: m.ListBankAccountsParams | None = None) -> Iterator[m.BankAccount]:
244
+ """Lazily walk every linked account, starting at params.page."""
245
+ for item in self._iterate(params, False):
246
+ yield item
247
+
248
+ def iterate_all(
249
+ self, params: m.ListBankAccountsParams | None = None
250
+ ) -> Iterator[m.BankAccount]:
251
+ """Lazily walk service accounts, requesting at most one page at a time."""
252
+ for item in self._iterate(params, True):
253
+ yield item
254
+
255
+
256
+ class Jobs(Resource):
257
+ """Async verification, polling and independent batch submission."""
258
+
259
+ def submit_bank(
260
+ self, payload: str, options: m.AsyncVerifyOptions | None = None
261
+ ) -> m.JobSubmission:
262
+ """Enqueue one bank slip; successful verification spends quota."""
263
+ body = encode(options) if options else {}
264
+ body["payload"] = _text(payload, 128)
265
+ return self._call(m.JobSubmission, "POST", "/verify/bank/async", json=body)
266
+
267
+ def submit_bank_batch(
268
+ self, slips: list[m.BatchSlip], options: m.BatchOptions | None = None
269
+ ) -> m.BatchSubmission:
270
+ """Enqueue 1-100 slips; each successful slip spends quota separately."""
271
+ if not slips:
272
+ raise InputError("EMPTY_BATCH", "At least one slip is required")
273
+ if len(slips) > 100:
274
+ raise InputError("BATCH_TOO_LARGE", "At most 100 slips per batch")
275
+ body = encode(options) if options else {}
276
+ body["slips"] = encode(slips)
277
+ return self._call(m.BatchSubmission, "POST", "/verify/bank/batch", json=body)
278
+
279
+ def submit_bank_many(
280
+ self, slips: list[m.BatchSlip], options: m.BatchOptions | None = None
281
+ ) -> list[m.BatchSubmission]:
282
+ """Submit chunks of 100 in order, retaining receipts on partial failure."""
283
+ if not slips:
284
+ raise InputError("EMPTY_BATCH", "At least one slip is required")
285
+ submitted: list[m.BatchSubmission] = []
286
+ for index in range(0, len(slips), 100):
287
+ try:
288
+ submitted.append(self.submit_bank_batch(slips[index : index + 100], options))
289
+ except Exception as cause:
290
+ raise BatchSubmitError(submitted, index // 100, cause) from None
291
+ return submitted
292
+
293
+ def get(self, job_id: str) -> m.Job:
294
+ """Read one job, including failed jobs without raising an API error."""
295
+ from urllib.parse import quote
296
+
297
+ return self._call(
298
+ m.Job, "GET", "/verify/bank/jobs/" + quote(_text(job_id, 255), safe=""), safe=True
299
+ )
300
+
301
+ def wait_for_job(self, job_id: str, *, interval: float = 2, timeout: float = 600) -> m.Job:
302
+ """Poll until done or failed, bounded by an overall timeout."""
303
+ import math
304
+
305
+ if (
306
+ not math.isfinite(interval)
307
+ or interval < 0.5
308
+ or not math.isfinite(timeout)
309
+ or timeout <= 0
310
+ ):
311
+ raise InputError("INVALID_ARGUMENT", "Invalid polling interval or timeout")
312
+ deadline = time.monotonic() + timeout
313
+ last: m.Job | None = None
314
+ while time.monotonic() < deadline:
315
+ # HTTPX phase timeouts alone do not bound a slow trickle or a custom
316
+ # transport. One worker bounds the caller's total wall-clock budget.
317
+ # No more polling/retries are started after the deadline.
318
+ replies: queue.Queue[httpx.Response | Exception] = queue.Queue(maxsize=1)
319
+
320
+ def fetch(replies: queue.Queue[httpx.Response | Exception] = replies) -> None:
321
+ try:
322
+ replies.put(
323
+ self._transport.request(
324
+ "GET",
325
+ "/verify/bank/jobs/"
326
+ + __import__("urllib.parse", fromlist=["quote"]).quote(
327
+ _text(job_id, 255), safe=""
328
+ ),
329
+ safe=True,
330
+ _deadline=deadline,
331
+ )
332
+ )
333
+ except Exception as error:
334
+ replies.put(error)
335
+
336
+ worker = threading.Thread(target=fetch, name="sdk-job-poll", daemon=True)
337
+ worker.start()
338
+ try:
339
+ response = replies.get(timeout=max(0, deadline - time.monotonic()))
340
+ except queue.Empty:
341
+ raise JobWaitTimeoutError(last) from None
342
+ if time.monotonic() >= deadline or isinstance(response, RequestTimeoutError):
343
+ raise JobWaitTimeoutError(last) from None
344
+ if isinstance(response, Exception):
345
+ raise response
346
+ job = _decode_response(m.Job, response, self._transport._key)
347
+ if time.monotonic() >= deadline:
348
+ raise JobWaitTimeoutError(last)
349
+ last = job
350
+ if last.status in ("done", "failed"):
351
+ return last
352
+ time.sleep(min(interval, max(0, deadline - time.monotonic())))
353
+ raise JobWaitTimeoutError(last)
354
+
355
+
356
+ class V1(Resource):
357
+ """Legacy API operations using the independent v1 base URL."""
358
+
359
+ def get_me(self) -> m.V1Me:
360
+ """Read application quota and credit."""
361
+ return self._call(m.V1Me, "GET", "/me", safe=True)
362
+
363
+ def verify_bank_by_payload(
364
+ self, payload: str, options: m.V1VerifyOptions | None = None
365
+ ) -> m.BankSlip:
366
+ """Verify a payload through the billed legacy GET endpoint."""
367
+ query = encode(options) if options else {}
368
+ query["payload"] = _text(payload, 255)
369
+ return self._call(
370
+ m.BankSlip, "GET", "/verify", params={k: _form(v) for k, v in query.items()}
371
+ )
372
+
373
+ def _verify(
374
+ self,
375
+ field: str,
376
+ value: str | FileInput,
377
+ options: m.V1VerifyOptions | None,
378
+ wallet: bool = False,
379
+ ) -> m.BankSlip | m.TrueWalletSlip:
380
+ body = encode(options) if options else {}
381
+ model: type[m.TrueWalletSlip] | type[m.BankSlip] = (
382
+ m.TrueWalletSlip if wallet else m.BankSlip
383
+ )
384
+ path = "/verify/truewallet" if wallet else "/verify"
385
+ if field == "file":
386
+ image = image_input(value, max_bytes=4194304)
387
+ return self._call(
388
+ model,
389
+ "POST",
390
+ path,
391
+ data={k: _form(v) for k, v in body.items()},
392
+ files={"file": (image.filename, image.data, image.content_type)},
393
+ )
394
+ body[field] = value
395
+ return self._call(model, "POST", path, json=body)
396
+
397
+ def verify_bank_by_image(
398
+ self, file: FileInput, options: m.V1VerifyOptions | None = None
399
+ ) -> m.BankSlip:
400
+ """Upload an image to v1 using multipart field file."""
401
+ return cast(m.BankSlip, self._verify("file", file, options))
402
+
403
+ def verify_bank_by_base64(
404
+ self, base64: str, options: m.V1VerifyOptions | None = None
405
+ ) -> m.BankSlip:
406
+ """Send base64 using the legacy JSON field image."""
407
+ return cast(m.BankSlip, self._verify("image", base64, options))
408
+
409
+ def verify_bank_by_url(self, url: str, options: m.V1VerifyOptions | None = None) -> m.BankSlip:
410
+ """Verify a remote image through v1."""
411
+ return cast(m.BankSlip, self._verify("url", _text(url, 2048), options))
412
+
413
+ def verify_truemoney_by_image(
414
+ self, file: FileInput, options: m.V1VerifyOptions | None = None
415
+ ) -> m.TrueWalletSlip:
416
+ """Upload a wallet image to the legacy endpoint."""
417
+ return cast(m.TrueWalletSlip, self._verify("file", file, options, True))
418
+
419
+ def generate_qr(self, request: m.QrRequest) -> m.QrCode:
420
+ """Generate a QR code without verification quota consumption."""
421
+ return self._call(m.QrCode, "POST", "/qr/generate", safe=True, json=encode(request))
422
+
423
+
424
+ class Client:
425
+ """Credential-safe API client; use a context manager to close owned connections."""
426
+
427
+ def __init__(
428
+ self,
429
+ api_key: str | None = None,
430
+ *,
431
+ v2_base_url: str | None = None,
432
+ v1_base_url: str | None = None,
433
+ timeout: float = 30,
434
+ max_retries: int = 2,
435
+ http: httpx.Client | None = None,
436
+ ) -> None:
437
+ key, v2, v1, agent = _settings(api_key, v2_base_url, v1_base_url)
438
+ try:
439
+ self._v2 = Transport(
440
+ key, v2, http=http, timeout=timeout, max_retries=max_retries, user_agent=agent
441
+ )
442
+ self._v1 = Transport(
443
+ key, v1, http=http, timeout=timeout, max_retries=max_retries, user_agent=agent
444
+ )
445
+ except ValueError:
446
+ raise InputError("INVALID_ARGUMENT", "Invalid client configuration") from None
447
+ self.verify = Verify(self._v2)
448
+ self.banks = Banks(self._v2)
449
+ self.bank_accounts = BankAccounts(self._v2)
450
+ self.jobs = Jobs(self._v2)
451
+ self.v1 = V1(self._v1)
452
+
453
+ def get_info(self) -> m.Info:
454
+ """Read application, branch, quota and credit information."""
455
+ response = self._v2.request("GET", "/info", safe=True)
456
+ return _decode_response(m.Info, response, self._v2._key)
457
+
458
+ def close(self) -> None:
459
+ """Close connections created by this client; injected HTTP clients remain open."""
460
+ self._v2.close()
461
+ self._v1.close()
462
+
463
+ def __enter__(self) -> Client:
464
+ return self
465
+
466
+ def __exit__(self, *_args: object) -> None:
467
+ self.close()
468
+
469
+
470
+ class AsyncResource:
471
+ """Internal resource base; transports remain owned by the parent client."""
472
+
473
+ def __init__(self, transport: AsyncTransport) -> None:
474
+ self._transport = transport
475
+
476
+ async def _call(
477
+ self, model: type[T], method: str, path: str, *, safe: bool = False, **kwargs: Any
478
+ ) -> T:
479
+ response = await self._transport.request(method, path, safe=safe, **kwargs)
480
+ return _decode_response(model, response, self._transport._key)
481
+
482
+
483
+ class AsyncVerify(AsyncResource):
484
+ """Billed bank and wallet verification operations."""
485
+
486
+ async def _verify(
487
+ self,
488
+ field: str,
489
+ value: str | FileInput,
490
+ options: m.VerifyOptions | None,
491
+ wallet: bool = False,
492
+ ) -> m.BankSlipResult | m.TrueWalletResult:
493
+ body = encode(options) if options else {}
494
+ path = "/verify/truewallet" if wallet else "/verify/bank"
495
+ model: type[m.TrueWalletResult] | type[m.BankSlipResult] = (
496
+ m.TrueWalletResult if wallet else m.BankSlipResult
497
+ )
498
+ if field == "image":
499
+ image = image_input(value, max_bytes=4194304)
500
+ return await self._call(
501
+ model,
502
+ "POST",
503
+ path,
504
+ data={k: _form(v) for k, v in body.items()},
505
+ files={"image": (image.filename, image.data, image.content_type)},
506
+ )
507
+ body[field] = value
508
+ return await self._call(model, "POST", path, json=body)
509
+
510
+ async def bank_by_payload(
511
+ self, payload: str, options: m.VerifyOptions | None = None
512
+ ) -> m.BankSlipResult:
513
+ """Verify one bank slip using payload; may spend quota."""
514
+ _text(payload, 128)
515
+ return cast(m.BankSlipResult, await self._verify("payload", payload, options, False))
516
+
517
+ async def bank_by_image(
518
+ self, file: FileInput, options: m.VerifyOptions | None = None
519
+ ) -> m.BankSlipResult:
520
+ """Verify one bank slip using image; may spend quota."""
521
+ return cast(m.BankSlipResult, await self._verify("image", file, options, False))
522
+
523
+ async def bank_by_base64(
524
+ self, base64: str, options: m.VerifyOptions | None = None
525
+ ) -> m.BankSlipResult:
526
+ """Verify one bank slip using base64; may spend quota."""
527
+ return cast(m.BankSlipResult, await self._verify("base64", base64, options, False))
528
+
529
+ async def bank_by_url(
530
+ self, url: str, options: m.VerifyOptions | None = None
531
+ ) -> m.BankSlipResult:
532
+ """Verify one bank slip using url; may spend quota."""
533
+ _text(url, 2048)
534
+ return cast(m.BankSlipResult, await self._verify("url", url, options, False))
535
+
536
+ async def truewallet_by_image(
537
+ self, file: FileInput, options: m.VerifyOptions | None = None
538
+ ) -> m.TrueWalletResult:
539
+ """Verify one wallet slip using image; may spend quota."""
540
+ return cast(m.TrueWalletResult, await self._verify("image", file, options, True))
541
+
542
+ async def truewallet_by_base64(
543
+ self, base64: str, options: m.VerifyOptions | None = None
544
+ ) -> m.TrueWalletResult:
545
+ """Verify one wallet slip using base64; may spend quota."""
546
+ return cast(m.TrueWalletResult, await self._verify("base64", base64, options, True))
547
+
548
+ async def truewallet_by_url(
549
+ self, url: str, options: m.VerifyOptions | None = None
550
+ ) -> m.TrueWalletResult:
551
+ """Verify one wallet slip using url; may spend quota."""
552
+ _text(url, 255)
553
+ return cast(m.TrueWalletResult, await self._verify("url", url, options, True))
554
+
555
+
556
+ class AsyncBanks(AsyncResource):
557
+ """Bank catalog."""
558
+
559
+ async def list(self) -> m.BankList:
560
+ """Return supported banks and their verification choices."""
561
+ return await self._call(m.BankList, "GET", "/banks", safe=True)
562
+
563
+
564
+ class AsyncBankAccounts(AsyncResource):
565
+ """Receiving account management and lazy pagination."""
566
+
567
+ async def _list(
568
+ self, params: m.ListBankAccountsParams | None, all_accounts: bool
569
+ ) -> m.BankAccountPage:
570
+ query = encode(params) if params else {}
571
+ if "page" in query and (type(query["page"]) is not int or query["page"] < 1):
572
+ raise InputError("INVALID_ARGUMENT", "page must be positive")
573
+ if "limit" in query and (type(query["limit"]) is not int or not 1 <= query["limit"] <= 100):
574
+ raise InputError("INVALID_ARGUMENT", "limit must be between 1 and 100")
575
+ return await self._call(
576
+ m.BankAccountPage,
577
+ "GET",
578
+ "/bank-accounts" + ("/all" if all_accounts else ""),
579
+ safe=True,
580
+ params={k: _form(v) for k, v in query.items()},
581
+ )
582
+
583
+ async def list(self, params: m.ListBankAccountsParams | None = None) -> m.BankAccountPage:
584
+ """Return one page linked to this branch."""
585
+ return await self._list(params, False)
586
+
587
+ async def list_all(self, params: m.ListBankAccountsParams | None = None) -> m.BankAccountPage:
588
+ """Return one page across the service."""
589
+ return await self._list(params, True)
590
+
591
+ async def get(self, id: int) -> m.BankAccount:
592
+ """Read one receiving account."""
593
+ return await self._call(m.BankAccount, "GET", "/bank-accounts/" + _id(id), safe=True)
594
+
595
+ async def create(self, params: m.CreateBankAccountParams) -> m.BankAccount:
596
+ """Create an account; ambiguous failures are never retried."""
597
+ return await self._call(m.BankAccount, "POST", "/bank-accounts", json=encode(params))
598
+
599
+ async def update(self, id: int, params: m.UpdateBankAccountParams) -> m.BankAccount:
600
+ """Update selected fields; explicit extra_verify=None clears that field."""
601
+ return await self._call(
602
+ m.BankAccount, "PATCH", "/bank-accounts/" + _id(id), json=encode(params)
603
+ )
604
+
605
+ async def link(self, id: int) -> m.LinkResult:
606
+ """Idempotently link an account to this branch."""
607
+ return await self._call(
608
+ m.LinkResult, "POST", "/bank-accounts/" + _id(id) + "/link", safe=True
609
+ )
610
+
611
+ async def unlink(self, id: int) -> m.UnlinkResult:
612
+ """Unlink an account from this branch."""
613
+ return await self._call(m.UnlinkResult, "DELETE", "/bank-accounts/" + _id(id) + "/link")
614
+
615
+ async def _iterate(
616
+ self, params: m.ListBankAccountsParams | None, all_accounts: bool
617
+ ) -> AsyncIterator[m.BankAccount]:
618
+ params = params or m.ListBankAccountsParams()
619
+ page = params.page if params.page is not UNSET else 1
620
+ limit = params.limit if params.limit is not UNSET else 100
621
+ while True:
622
+ response = await self._list(replace(params, page=page, limit=limit), all_accounts)
623
+ for item in response.items:
624
+ yield item
625
+ if not response.items or page * limit >= response.total:
626
+ break
627
+ page += 1
628
+
629
+ async def iterate(
630
+ self, params: m.ListBankAccountsParams | None = None
631
+ ) -> AsyncIterator[m.BankAccount]:
632
+ """Lazily walk every linked account, starting at params.page."""
633
+ async for item in self._iterate(params, False):
634
+ yield item
635
+
636
+ async def iterate_all(
637
+ self, params: m.ListBankAccountsParams | None = None
638
+ ) -> AsyncIterator[m.BankAccount]:
639
+ """Lazily walk service accounts, requesting at most one page at a time."""
640
+ async for item in self._iterate(params, True):
641
+ yield item
642
+
643
+
644
+ class AsyncJobs(AsyncResource):
645
+ """Async verification, polling and independent batch submission."""
646
+
647
+ async def submit_bank(
648
+ self, payload: str, options: m.AsyncVerifyOptions | None = None
649
+ ) -> m.JobSubmission:
650
+ """Enqueue one bank slip; successful verification spends quota."""
651
+ body = encode(options) if options else {}
652
+ body["payload"] = _text(payload, 128)
653
+ return await self._call(m.JobSubmission, "POST", "/verify/bank/async", json=body)
654
+
655
+ async def submit_bank_batch(
656
+ self, slips: list[m.BatchSlip], options: m.BatchOptions | None = None
657
+ ) -> m.BatchSubmission:
658
+ """Enqueue 1-100 slips; each successful slip spends quota separately."""
659
+ if not slips:
660
+ raise InputError("EMPTY_BATCH", "At least one slip is required")
661
+ if len(slips) > 100:
662
+ raise InputError("BATCH_TOO_LARGE", "At most 100 slips per batch")
663
+ body = encode(options) if options else {}
664
+ body["slips"] = encode(slips)
665
+ return await self._call(m.BatchSubmission, "POST", "/verify/bank/batch", json=body)
666
+
667
+ async def submit_bank_many(
668
+ self, slips: list[m.BatchSlip], options: m.BatchOptions | None = None
669
+ ) -> list[m.BatchSubmission]:
670
+ """Submit chunks of 100 in order, retaining receipts on partial failure."""
671
+ if not slips:
672
+ raise InputError("EMPTY_BATCH", "At least one slip is required")
673
+ submitted: list[m.BatchSubmission] = []
674
+ for index in range(0, len(slips), 100):
675
+ try:
676
+ submitted.append(await self.submit_bank_batch(slips[index : index + 100], options))
677
+ except Exception as cause:
678
+ raise BatchSubmitError(submitted, index // 100, cause) from None
679
+ return submitted
680
+
681
+ async def get(self, job_id: str) -> m.Job:
682
+ """Read one job, including failed jobs without raising an API error."""
683
+ from urllib.parse import quote
684
+
685
+ return await self._call(
686
+ m.Job, "GET", "/verify/bank/jobs/" + quote(_text(job_id, 255), safe=""), safe=True
687
+ )
688
+
689
+ async def wait_for_job(
690
+ self, job_id: str, *, interval: float = 2, timeout: float = 600
691
+ ) -> m.Job:
692
+ """Poll until done or failed, bounded by an overall timeout."""
693
+ import math
694
+
695
+ if (
696
+ not math.isfinite(interval)
697
+ or interval < 0.5
698
+ or not math.isfinite(timeout)
699
+ or timeout <= 0
700
+ ):
701
+ raise InputError("INVALID_ARGUMENT", "Invalid polling interval or timeout")
702
+ deadline = time.monotonic() + timeout
703
+ last: m.Job | None = None
704
+ while time.monotonic() < deadline:
705
+
706
+ async def fetch() -> m.Job:
707
+ response = await self._transport.request(
708
+ "GET",
709
+ "/verify/bank/jobs/"
710
+ + __import__("urllib.parse", fromlist=["quote"]).quote(
711
+ _text(job_id, 255), safe=""
712
+ ),
713
+ safe=True,
714
+ _deadline=deadline,
715
+ )
716
+ return _decode_response(m.Job, response, self._transport._key)
717
+
718
+ task = asyncio.create_task(fetch())
719
+ # Retrieve an eventual failure even if an injected transport suppresses
720
+ # cancellation. Such a late result never becomes last_job.
721
+ task.add_done_callback(
722
+ lambda finished: None if finished.cancelled() else finished.exception()
723
+ )
724
+ try:
725
+ done, _ = await asyncio.wait({task}, timeout=max(0, deadline - time.monotonic()))
726
+ except asyncio.CancelledError:
727
+ task.cancel()
728
+ raise
729
+ if not done:
730
+ task.cancel()
731
+ raise JobWaitTimeoutError(last)
732
+ if time.monotonic() >= deadline:
733
+ raise JobWaitTimeoutError(last)
734
+ try:
735
+ job = task.result()
736
+ except RequestTimeoutError:
737
+ raise JobWaitTimeoutError(last) from None
738
+ if time.monotonic() >= deadline:
739
+ raise JobWaitTimeoutError(last)
740
+ last = job
741
+ if last.status in ("done", "failed"):
742
+ return last
743
+ await asyncio.sleep(min(interval, max(0, deadline - time.monotonic())))
744
+ raise JobWaitTimeoutError(last)
745
+
746
+
747
+ class AsyncV1(AsyncResource):
748
+ """Legacy API operations using the independent v1 base URL."""
749
+
750
+ async def get_me(self) -> m.V1Me:
751
+ """Read application quota and credit."""
752
+ return await self._call(m.V1Me, "GET", "/me", safe=True)
753
+
754
+ async def verify_bank_by_payload(
755
+ self, payload: str, options: m.V1VerifyOptions | None = None
756
+ ) -> m.BankSlip:
757
+ """Verify a payload through the billed legacy GET endpoint."""
758
+ query = encode(options) if options else {}
759
+ query["payload"] = _text(payload, 255)
760
+ return await self._call(
761
+ m.BankSlip, "GET", "/verify", params={k: _form(v) for k, v in query.items()}
762
+ )
763
+
764
+ async def _verify(
765
+ self,
766
+ field: str,
767
+ value: str | FileInput,
768
+ options: m.V1VerifyOptions | None,
769
+ wallet: bool = False,
770
+ ) -> m.BankSlip | m.TrueWalletSlip:
771
+ body = encode(options) if options else {}
772
+ model: type[m.TrueWalletSlip] | type[m.BankSlip] = (
773
+ m.TrueWalletSlip if wallet else m.BankSlip
774
+ )
775
+ path = "/verify/truewallet" if wallet else "/verify"
776
+ if field == "file":
777
+ image = image_input(value, max_bytes=4194304)
778
+ return await self._call(
779
+ model,
780
+ "POST",
781
+ path,
782
+ data={k: _form(v) for k, v in body.items()},
783
+ files={"file": (image.filename, image.data, image.content_type)},
784
+ )
785
+ body[field] = value
786
+ return await self._call(model, "POST", path, json=body)
787
+
788
+ async def verify_bank_by_image(
789
+ self, file: FileInput, options: m.V1VerifyOptions | None = None
790
+ ) -> m.BankSlip:
791
+ """Upload an image to v1 using multipart field file."""
792
+ return cast(m.BankSlip, await self._verify("file", file, options))
793
+
794
+ async def verify_bank_by_base64(
795
+ self, base64: str, options: m.V1VerifyOptions | None = None
796
+ ) -> m.BankSlip:
797
+ """Send base64 using the legacy JSON field image."""
798
+ return cast(m.BankSlip, await self._verify("image", base64, options))
799
+
800
+ async def verify_bank_by_url(
801
+ self, url: str, options: m.V1VerifyOptions | None = None
802
+ ) -> m.BankSlip:
803
+ """Verify a remote image through v1."""
804
+ return cast(m.BankSlip, await self._verify("url", _text(url, 2048), options))
805
+
806
+ async def verify_truemoney_by_image(
807
+ self, file: FileInput, options: m.V1VerifyOptions | None = None
808
+ ) -> m.TrueWalletSlip:
809
+ """Upload a wallet image to the legacy endpoint."""
810
+ return cast(m.TrueWalletSlip, await self._verify("file", file, options, True))
811
+
812
+ async def generate_qr(self, request: m.QrRequest) -> m.QrCode:
813
+ """Generate a QR code without verification quota consumption."""
814
+ return await self._call(m.QrCode, "POST", "/qr/generate", safe=True, json=encode(request))
815
+
816
+
817
+ class AsyncClient:
818
+ """Credential-safe API client; use a context manager to close owned connections."""
819
+
820
+ def __init__(
821
+ self,
822
+ api_key: str | None = None,
823
+ *,
824
+ v2_base_url: str | None = None,
825
+ v1_base_url: str | None = None,
826
+ timeout: float = 30,
827
+ max_retries: int = 2,
828
+ http: httpx.AsyncClient | None = None,
829
+ ) -> None:
830
+ key, v2, v1, agent = _settings(api_key, v2_base_url, v1_base_url)
831
+ try:
832
+ self._v2 = AsyncTransport(
833
+ key, v2, http=http, timeout=timeout, max_retries=max_retries, user_agent=agent
834
+ )
835
+ self._v1 = AsyncTransport(
836
+ key, v1, http=http, timeout=timeout, max_retries=max_retries, user_agent=agent
837
+ )
838
+ except ValueError:
839
+ raise InputError("INVALID_ARGUMENT", "Invalid client configuration") from None
840
+ self.verify = AsyncVerify(self._v2)
841
+ self.banks = AsyncBanks(self._v2)
842
+ self.bank_accounts = AsyncBankAccounts(self._v2)
843
+ self.jobs = AsyncJobs(self._v2)
844
+ self.v1 = AsyncV1(self._v1)
845
+
846
+ async def get_info(self) -> m.Info:
847
+ """Read application, branch, quota and credit information."""
848
+ response = await self._v2.request("GET", "/info", safe=True)
849
+ return _decode_response(m.Info, response, self._v2._key)
850
+
851
+ async def close(self) -> None:
852
+ """Close connections created by this client; injected HTTP clients remain open."""
853
+ await self._v2.close()
854
+ await self._v1.close()
855
+
856
+ async def __aenter__(self) -> AsyncClient:
857
+ return self
858
+
859
+ async def __aexit__(self, *_args: object) -> None:
860
+ await self.close()