pacspace-sdk 0.1.2__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.
@@ -0,0 +1,35 @@
1
+ from .pacspace import PacSpace
2
+ from .errors import (
3
+ PacSpaceError,
4
+ InvalidApiKeyError,
5
+ InsufficientCreditsError,
6
+ NotFoundError,
7
+ ContractNotDeployedError,
8
+ RateLimitError,
9
+ CadenceLimitError,
10
+ ServiceUnavailableError,
11
+ ValidationError,
12
+ InvalidScopeCombinationError,
13
+ ScopeTooWideError,
14
+ TimeoutError,
15
+ WebhookVerificationError,
16
+ )
17
+ from .webhooks.verify import Webhooks
18
+
19
+ __all__ = [
20
+ "PacSpace",
21
+ "PacSpaceError",
22
+ "InvalidApiKeyError",
23
+ "InsufficientCreditsError",
24
+ "NotFoundError",
25
+ "ContractNotDeployedError",
26
+ "RateLimitError",
27
+ "CadenceLimitError",
28
+ "ServiceUnavailableError",
29
+ "ValidationError",
30
+ "InvalidScopeCombinationError",
31
+ "ScopeTooWideError",
32
+ "TimeoutError",
33
+ "WebhookVerificationError",
34
+ "Webhooks",
35
+ ]
@@ -0,0 +1 @@
1
+ """Internal SDK helpers. Not part of the public SDK surface."""
@@ -0,0 +1,43 @@
1
+ """Internal batch transport used by SubmissionCoordinator only.
2
+
3
+ Not part of the public SDK surface. Do not reference from public code paths.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, Dict, Iterable, List, Optional
8
+
9
+ from ..client import HttpClient
10
+
11
+
12
+ def submit_internal_batch(
13
+ client: HttpClient,
14
+ deltas: Iterable[Dict[str, Any]],
15
+ options: Optional[Dict[str, Any]] = None,
16
+ ) -> Dict[str, Any]:
17
+ """Submit deltas via the internal transport used by the summary submission coordinator.
18
+
19
+ This helper is internal; do not invoke it from public SDK code paths.
20
+ """
21
+ payload_deltas: List[Dict[str, Any]] = list(deltas)
22
+ response = client.post(
23
+ "/api/v1/balance/delta/batch",
24
+ {"deltas": payload_deltas},
25
+ options,
26
+ )
27
+ return _normalize_internal_batch_response(response)
28
+
29
+
30
+ def _normalize_internal_batch_response(data: Any) -> Dict[str, Any]:
31
+ if not isinstance(data, dict):
32
+ return {"totalQueued": 0, "totalFailed": 0, "results": []}
33
+
34
+ results = data.get("results")
35
+ if not isinstance(results, list):
36
+ results = []
37
+
38
+ return {
39
+ "totalQueued": int(data.get("totalQueued", len(results))),
40
+ "totalFailed": int(data.get("totalFailed", 0)),
41
+ "results": results,
42
+ "_efficiency": data.get("_efficiency"),
43
+ }
@@ -0,0 +1,535 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Any, Dict, Iterable, Optional
5
+ from urllib.parse import quote, urlencode
6
+
7
+ from .client import HttpClient
8
+ from .polling import poll_until_terminal
9
+ from .submission import SubmissionCoordinator
10
+ from ._internal.batch_transport import submit_internal_batch
11
+
12
+
13
+ def _build_query(params: Dict[str, Any]) -> str:
14
+ compact = {key: value for key, value in params.items() if value is not None}
15
+ if not compact:
16
+ return ""
17
+ return "?" + urlencode(compact, doseq=True)
18
+
19
+
20
+ class BalanceResource:
21
+ def __init__(
22
+ self,
23
+ client: HttpClient,
24
+ submission_options: Optional[Dict[str, Any]] = None,
25
+ ) -> None:
26
+ self._client = client
27
+ self.submission = SubmissionCoordinator(
28
+ submit_batch=lambda deltas: submit_internal_batch(self._client, deltas),
29
+ options=submission_options,
30
+ )
31
+
32
+ @staticmethod
33
+ def _normalize_status(status: Any) -> str:
34
+ normalized = str(status or "").upper()
35
+ if normalized in {"QUEUED", "PROCESSING", "VERIFIED", "FAILED"}:
36
+ return normalized
37
+ return "QUEUED"
38
+
39
+ @staticmethod
40
+ def _to_api_checkpoint_type(checkpoint_type: Any) -> Any:
41
+ return checkpoint_type
42
+
43
+ @staticmethod
44
+ def _validate_scope_mode(options: Dict[str, Any]) -> None:
45
+ period = options.get("period")
46
+ time_preset = options.get("timePreset")
47
+ start_date = options.get("startDate")
48
+ end_date = options.get("endDate")
49
+ starting_checkpoint = options.get("startingCheckpoint")
50
+
51
+ has_scope = any(
52
+ value is not None for value in (period, time_preset, start_date, end_date)
53
+ )
54
+ if starting_checkpoint is not None and has_scope:
55
+ raise ValueError(
56
+ "startingCheckpoint cannot be combined with period, timePreset, startDate, or endDate."
57
+ )
58
+
59
+ if period is not None and any(
60
+ value is not None for value in (time_preset, start_date, end_date)
61
+ ):
62
+ raise ValueError(
63
+ "period cannot be combined with timePreset, startDate, or endDate."
64
+ )
65
+
66
+ if time_preset is not None and time_preset != "custom" and any(
67
+ value is not None for value in (start_date, end_date)
68
+ ):
69
+ raise ValueError(
70
+ "startDate/endDate can only be used when timePreset is 'custom' or omitted."
71
+ )
72
+
73
+ if time_preset == "custom" and (start_date is None or end_date is None):
74
+ raise ValueError("timePreset='custom' requires both startDate and endDate.")
75
+
76
+ if time_preset is None and ((start_date is None) != (end_date is None)):
77
+ raise ValueError("startDate and endDate must be provided together.")
78
+
79
+ @staticmethod
80
+ def _to_utc_iso(dt: datetime) -> str:
81
+ return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
82
+
83
+ def _normalize_payload(self, value: Any) -> Any:
84
+ if isinstance(value, list):
85
+ return [self._normalize_payload(item) for item in value]
86
+ if not isinstance(value, dict):
87
+ return value
88
+
89
+ normalized: Dict[str, Any] = {
90
+ key: self._normalize_payload(payload) for key, payload in value.items()
91
+ }
92
+
93
+ if "status" in normalized:
94
+ normalized["status"] = self._normalize_status(normalized["status"])
95
+
96
+ return normalized
97
+
98
+ def _normalize_invoice_proof_payload(self, value: Any) -> Dict[str, Any]:
99
+ normalized = self._normalize_payload(value)
100
+ if not isinstance(normalized, dict):
101
+ return {}
102
+
103
+ for key in (
104
+ "verificationApiUrl",
105
+ "accessHint",
106
+ "customerLinkUrl",
107
+ "customerLinkHost",
108
+ ):
109
+ field_value = normalized.get(key)
110
+ normalized[key] = None if field_value is None else str(field_value)
111
+
112
+ return normalized
113
+
114
+ def emit(
115
+ self,
116
+ customer_id: str,
117
+ delta: float,
118
+ reason: str,
119
+ options: Optional[Dict[str, Any]] = None,
120
+ ) -> Dict[str, Any]:
121
+ options = options or {}
122
+ body: Dict[str, Any] = {
123
+ "customerId": customer_id,
124
+ "delta": delta,
125
+ "reason": reason,
126
+ }
127
+ if "referenceId" in options:
128
+ body["referenceId"] = options["referenceId"]
129
+ if "metadata" in options:
130
+ body["metadata"] = options["metadata"]
131
+
132
+ request_options = {
133
+ key: value
134
+ for key, value in options.items()
135
+ if key not in {"referenceId", "metadata"}
136
+ }
137
+ response = self._client.post("/api/v1/balance/delta", body, request_options)
138
+ return self._normalize_payload(response)
139
+
140
+ def emit_and_wait(
141
+ self,
142
+ customer_id: str,
143
+ delta: float,
144
+ reason: str,
145
+ options: Optional[Dict[str, Any]] = None,
146
+ ) -> Dict[str, Any]:
147
+ options = options or {}
148
+ timeout_ms = int(options.get("timeout", 60_000))
149
+ poll_interval_ms = int(options.get("pollInterval", 2_000))
150
+ emit_options = {
151
+ key: value
152
+ for key, value in options.items()
153
+ if key not in {"timeout", "pollInterval"}
154
+ }
155
+
156
+ initial = self.emit(customer_id, delta, reason, emit_options)
157
+ if str(initial.get("status", "")).upper() in {"VERIFIED", "FAILED"}:
158
+ return initial
159
+
160
+ record_id = str(initial["recordId"])
161
+ return poll_until_terminal(
162
+ lambda: self.delta_status(record_id),
163
+ timeout_ms=timeout_ms,
164
+ poll_interval_ms=poll_interval_ms,
165
+ )
166
+
167
+ def delta_status(
168
+ self,
169
+ record_id: str,
170
+ options: Optional[Dict[str, Any]] = None,
171
+ ) -> Dict[str, Any]:
172
+ response = self._client.get(f"/api/v1/balance/delta/{quote(record_id, safe='')}", options)
173
+ return self._normalize_payload(response)
174
+
175
+ def get_record_status(
176
+ self,
177
+ record_id: str,
178
+ options: Optional[Dict[str, Any]] = None,
179
+ ) -> Dict[str, Any]:
180
+ return self.delta_status(record_id, options)
181
+
182
+ def wait_for_verified(
183
+ self,
184
+ record_id: str,
185
+ options: Optional[Dict[str, Any]] = None,
186
+ ) -> Dict[str, Any]:
187
+ options = options or {}
188
+ timeout_ms = int(options.get("timeout", 60_000))
189
+ poll_interval_ms = int(options.get("pollInterval", 2_000))
190
+ request_options = {
191
+ key: value
192
+ for key, value in options.items()
193
+ if key not in {"timeout", "pollInterval"}
194
+ }
195
+ return poll_until_terminal(
196
+ lambda: self.get_record_status(record_id, request_options),
197
+ timeout_ms=timeout_ms,
198
+ poll_interval_ms=poll_interval_ms,
199
+ )
200
+
201
+ def usage(self, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
202
+ response = self._client.get("/api/v1/balance/usage", options)
203
+ return self._normalize_payload(response)
204
+
205
+ def gaps(
206
+ self,
207
+ customer_id: str,
208
+ options: Optional[Dict[str, Any]] = None,
209
+ ) -> Dict[str, Any]:
210
+ options = options or {}
211
+ query = _build_query(
212
+ {
213
+ "fromSequence": options.get("fromSequence"),
214
+ "toSequence": options.get("toSequence"),
215
+ "limit": options.get("limit"),
216
+ }
217
+ )
218
+ request_options = {
219
+ key: value
220
+ for key, value in options.items()
221
+ if key not in {"fromSequence", "toSequence", "limit"}
222
+ }
223
+ path = f"/api/v1/balance/gaps/{quote(customer_id, safe='')}{query}"
224
+ response = self._client.get(path, request_options)
225
+ return self._normalize_payload(response)
226
+
227
+ def queue_summary(self, summary: Dict[str, Any]) -> Dict[str, Any]:
228
+ return self.submission.queue_summary(summary)
229
+
230
+ def queue_summaries(self, summaries: Iterable[Dict[str, Any]]) -> list[Dict[str, Any]]:
231
+ return self.submission.queue_summaries(summaries)
232
+
233
+ def flush_summaries(self, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
234
+ return self.submission.flush_summaries(options)
235
+
236
+ def start_summary_scheduler(self) -> None:
237
+ self.submission.start()
238
+
239
+ def stop_summary_scheduler(self) -> None:
240
+ self.submission.stop()
241
+
242
+ def get_summary_queue_state(self) -> Dict[str, Any]:
243
+ return self.submission.get_queue_state()
244
+
245
+ def shutdown_summary_scheduler(self) -> None:
246
+ self.submission.shutdown()
247
+
248
+ def derive(
249
+ self,
250
+ customer_id: str,
251
+ options: Optional[Dict[str, Any]] = None,
252
+ ) -> Dict[str, Any]:
253
+ options = options or {}
254
+ self._validate_scope_mode(options)
255
+ checkpoint_type = self._to_api_checkpoint_type(options.get("startingCheckpointType"))
256
+ query = _build_query(
257
+ {
258
+ "startingBalance": options.get("startingBalance"),
259
+ "startingCheckpoint": options.get("startingCheckpoint"),
260
+ "startingCheckpointType": checkpoint_type,
261
+ "limit": options.get("limit"),
262
+ "offset": options.get("offset"),
263
+ "period": options.get("period"),
264
+ "timePreset": options.get("timePreset"),
265
+ "startDate": options.get("startDate"),
266
+ "endDate": options.get("endDate"),
267
+ }
268
+ )
269
+ request_options = {
270
+ key: value
271
+ for key, value in options.items()
272
+ if key
273
+ not in {
274
+ "startingBalance",
275
+ "startingCheckpoint",
276
+ "startingCheckpointType",
277
+ "limit",
278
+ "offset",
279
+ "period",
280
+ "timePreset",
281
+ "startDate",
282
+ "endDate",
283
+ }
284
+ }
285
+ path = f"/api/v1/balance/derive/{quote(customer_id, safe='')}{query}"
286
+ response = self._client.get(path, request_options)
287
+ return self._normalize_payload(response)
288
+
289
+ def derive_current_month(
290
+ self,
291
+ customer_id: str,
292
+ options: Optional[Dict[str, Any]] = None,
293
+ ) -> Dict[str, Any]:
294
+ merged = {**(options or {}), "timePreset": "current_month"}
295
+ return self.derive(customer_id, merged)
296
+
297
+ def derive_for_period(
298
+ self,
299
+ customer_id: str,
300
+ period: str,
301
+ options: Optional[Dict[str, Any]] = None,
302
+ ) -> Dict[str, Any]:
303
+ merged = {**(options or {}), "period": period}
304
+ return self.derive(customer_id, merged)
305
+
306
+ def derive_months_back(
307
+ self,
308
+ customer_id: str,
309
+ months: int,
310
+ options: Optional[Dict[str, Any]] = None,
311
+ ) -> Dict[str, Any]:
312
+ if not isinstance(months, int) or months < 1:
313
+ raise ValueError("months must be a positive integer.")
314
+
315
+ now = datetime.now(timezone.utc)
316
+ end_year = now.year + (1 if now.month == 12 else 0)
317
+ end_month = 1 if now.month == 12 else now.month + 1
318
+ end_exclusive = datetime(end_year, end_month, 1, tzinfo=timezone.utc)
319
+
320
+ start_month = now.month - (months - 1)
321
+ start_year = now.year
322
+ while start_month <= 0:
323
+ start_month += 12
324
+ start_year -= 1
325
+ start_inclusive = datetime(start_year, start_month, 1, tzinfo=timezone.utc)
326
+
327
+ merged = {
328
+ **(options or {}),
329
+ "timePreset": "custom",
330
+ "startDate": self._to_utc_iso(start_inclusive),
331
+ "endDate": self._to_utc_iso(end_exclusive),
332
+ }
333
+ return self.derive(customer_id, merged)
334
+
335
+ def compare(
336
+ self,
337
+ customer_id: str,
338
+ balances: Dict[str, Any],
339
+ options: Optional[Dict[str, Any]] = None,
340
+ ) -> Dict[str, Any]:
341
+ options = options or {}
342
+ self._validate_scope_mode(options)
343
+ body: Dict[str, Any] = {
344
+ "customerId": customer_id,
345
+ "yourBalance": balances["yours"],
346
+ "theirBalance": balances["theirs"],
347
+ "startingBalance": options.get("startingBalance", 0),
348
+ }
349
+ if options.get("startingCheckpoint") is not None:
350
+ body["startingCheckpoint"] = options["startingCheckpoint"]
351
+ if options.get("startingCheckpointType") is not None:
352
+ body["startingCheckpointType"] = self._to_api_checkpoint_type(
353
+ options["startingCheckpointType"]
354
+ )
355
+ for key in ("period", "timePreset", "startDate", "endDate"):
356
+ if options.get(key) is not None:
357
+ body[key] = options[key]
358
+
359
+ request_options = {
360
+ key: value
361
+ for key, value in options.items()
362
+ if key
363
+ not in {
364
+ "startingBalance",
365
+ "startingCheckpoint",
366
+ "startingCheckpointType",
367
+ "period",
368
+ "timePreset",
369
+ "startDate",
370
+ "endDate",
371
+ }
372
+ }
373
+ response = self._client.post("/api/v1/balance/compare", body, request_options)
374
+ return self._normalize_payload(response)
375
+
376
+ def receipt(
377
+ self,
378
+ customer_id: str,
379
+ options: Optional[Dict[str, Any]] = None,
380
+ ) -> Dict[str, Any]:
381
+ options = options or {}
382
+ has_scoped_receipt = any(
383
+ options.get(key) is not None for key in ("period", "timePreset", "startDate", "endDate")
384
+ )
385
+ if has_scoped_receipt:
386
+ return self._receipt_for_period(customer_id, options)
387
+
388
+ request_options = {
389
+ key: value
390
+ for key, value in options.items()
391
+ if key not in {"period", "timePreset", "startDate", "endDate"}
392
+ }
393
+ response = self._client.get(
394
+ f"/api/v1/balance/receipt/{quote(customer_id, safe='')}",
395
+ request_options or None,
396
+ )
397
+ return self._normalize_payload(response)
398
+
399
+ def _receipt_for_period(
400
+ self,
401
+ customer_id: str,
402
+ options: Optional[Dict[str, Any]] = None,
403
+ ) -> Dict[str, Any]:
404
+ options = options or {}
405
+ query = _build_query(
406
+ {
407
+ "period": options.get("period"),
408
+ "timePreset": options.get("timePreset"),
409
+ "startDate": options.get("startDate"),
410
+ "endDate": options.get("endDate"),
411
+ }
412
+ )
413
+ request_options = {
414
+ key: value
415
+ for key, value in options.items()
416
+ if key not in {"period", "timePreset", "startDate", "endDate"}
417
+ }
418
+ path = f"/api/v1/balance/invoice-proof/{quote(customer_id, safe='')}{query}"
419
+ response = self._client.get(path, request_options)
420
+ return self._normalize_invoice_proof_payload(response)
421
+
422
+ def checkpoint(
423
+ self,
424
+ customer_id: Optional[str] = None,
425
+ options: Optional[Dict[str, Any]] = None,
426
+ ) -> Dict[str, Any]:
427
+ options = options or {}
428
+ body: Dict[str, Any] = {}
429
+ if customer_id is not None:
430
+ body["customerId"] = customer_id
431
+
432
+ for key in ("period", "timePreset", "startDate", "endDate", "mode", "fingerprints"):
433
+ if options.get(key) is not None:
434
+ body[key] = options[key]
435
+
436
+ request_options = {
437
+ key: value
438
+ for key, value in options.items()
439
+ if key not in {"period", "timePreset", "startDate", "endDate", "mode", "fingerprints"}
440
+ }
441
+ response = self._client.post("/api/v1/balance/checkpoint", body, request_options)
442
+ return self._normalize_payload(response)
443
+
444
+ def list_checkpoints(self, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
445
+ options = options or {}
446
+ query = _build_query(
447
+ {
448
+ "customerId": options.get("customerId"),
449
+ "period": options.get("period"),
450
+ "timePreset": options.get("timePreset"),
451
+ "startDate": options.get("startDate"),
452
+ "endDate": options.get("endDate"),
453
+ "limit": options.get("limit"),
454
+ "offset": options.get("offset"),
455
+ }
456
+ )
457
+ request_options = {
458
+ key: value
459
+ for key, value in options.items()
460
+ if key not in {"customerId", "period", "timePreset", "startDate", "endDate", "limit", "offset"}
461
+ }
462
+ response = self._client.get(f"/api/v1/balance/checkpoints{query}", request_options)
463
+ return self._normalize_payload(response)
464
+
465
+ def list_webhook_deliveries(self, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
466
+ options = options or {}
467
+ query = _build_query(
468
+ {
469
+ "status": options.get("status"),
470
+ "limit": options.get("limit"),
471
+ "offset": options.get("offset"),
472
+ }
473
+ )
474
+ request_options = {
475
+ key: value
476
+ for key, value in options.items()
477
+ if key not in {"status", "limit", "offset"}
478
+ }
479
+ response = self._client.get(f"/api/v1/balance/webhooks{query}", request_options)
480
+ return self._normalize_payload(response)
481
+
482
+ def retry_webhook(
483
+ self,
484
+ event_id: str,
485
+ options: Optional[Dict[str, Any]] = None,
486
+ ) -> Dict[str, Any]:
487
+ path = f"/api/v1/balance/webhooks/{quote(event_id, safe='')}/retry"
488
+ response = self._client.post(path, {}, options)
489
+ return self._normalize_payload(response)
490
+
491
+ def customers(self, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
492
+ options = options or {}
493
+ query = _build_query(
494
+ {
495
+ "search": options.get("search"),
496
+ "limit": options.get("limit"),
497
+ "page": options.get("page"),
498
+ }
499
+ )
500
+ request_options = {
501
+ key: value
502
+ for key, value in options.items()
503
+ if key not in {"search", "limit", "page"}
504
+ }
505
+ response = self._client.get(f"/api/v1/balance/customers{query}", request_options)
506
+ return self._normalize_payload(response)
507
+
508
+ def customer(
509
+ self,
510
+ customer_id: str,
511
+ options: Optional[Dict[str, Any]] = None,
512
+ ) -> Dict[str, Any]:
513
+ options = options or {}
514
+ query = _build_query(
515
+ {
516
+ "deltaPage": options.get("deltaPage"),
517
+ "deltaLimit": options.get("deltaLimit"),
518
+ "period": options.get("period"),
519
+ }
520
+ )
521
+ request_options = {
522
+ key: value
523
+ for key, value in options.items()
524
+ if key not in {"deltaPage", "deltaLimit", "period"}
525
+ }
526
+ path = f"/api/v1/balance/customers/{quote(customer_id, safe='')}{query}"
527
+ response = self._client.get(path, request_options)
528
+ return self._normalize_payload(response)
529
+
530
+ def invoice_proof(
531
+ self,
532
+ customer_id: str,
533
+ options: Optional[Dict[str, Any]] = None,
534
+ ) -> Dict[str, Any]:
535
+ return self._receipt_for_period(customer_id, options)