fazercards-python-sdk 0.1.1__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,515 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field, replace
4
+ from datetime import datetime
5
+ from decimal import Decimal
6
+ from typing import Any, Mapping, Optional
7
+
8
+ from .enums import OrderKind, OrderStatus
9
+ from .money import parse
10
+
11
+
12
+ def _pick(body: Mapping[str, Any], *keys: str) -> Any:
13
+ for key in keys:
14
+ value = body.get(key)
15
+ if value is not None and value != "":
16
+ return value
17
+ return None
18
+
19
+
20
+ def _str(body: Mapping[str, Any], *keys: str) -> Optional[str]:
21
+ value = _pick(body, *keys)
22
+ return None if value is None else str(value)
23
+
24
+
25
+ def _int(body: Mapping[str, Any], *keys: str) -> Optional[int]:
26
+ value = _pick(body, *keys)
27
+ if value is None or isinstance(value, bool):
28
+ return None
29
+ try:
30
+ return int(value)
31
+ except (TypeError, ValueError):
32
+ return None
33
+
34
+
35
+ def _bool(body: Mapping[str, Any], *keys: str) -> Optional[bool]:
36
+ value = _pick(body, *keys)
37
+ return value if isinstance(value, bool) else None
38
+
39
+
40
+ def _money(body: Mapping[str, Any], *keys: str) -> Optional[Decimal]:
41
+ return parse(_pick(body, *keys))
42
+
43
+
44
+ def _when(body: Mapping[str, Any], *keys: str) -> Optional[datetime]:
45
+ value = _pick(body, *keys)
46
+ if not isinstance(value, str):
47
+ return None
48
+ try:
49
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
50
+ except ValueError:
51
+ return None
52
+
53
+
54
+ def _map(value: Any) -> Mapping[str, Any]:
55
+ return value if isinstance(value, Mapping) else {}
56
+
57
+
58
+ def _list(value: Any) -> list[Any]:
59
+ return list(value) if isinstance(value, (list, tuple)) else []
60
+
61
+
62
+ def card_code(card: Any) -> str:
63
+ if isinstance(card, Mapping):
64
+ parts = [str(card[key]) for key in ("code", "serial", "pin") if card.get(key) not in (None, "")]
65
+ return " / ".join(parts) if parts else str(dict(card))
66
+ return str(card)
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Balance:
71
+ amount: Decimal
72
+ currency: str
73
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
74
+
75
+ @classmethod
76
+ def from_api(cls, body: Mapping[str, Any]) -> Balance:
77
+ return cls(_money(body, "balance") or Decimal("0"), _str(body, "currency") or "USD", dict(body))
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class Account:
82
+ login: Optional[str]
83
+ email: Optional[str]
84
+ plan: Optional[str]
85
+ plan_expires_at: Optional[datetime]
86
+ plan_auto_renew: Optional[bool]
87
+ subscription_active: Optional[bool]
88
+ total_spent: Optional[Decimal]
89
+ total_orders: Optional[int]
90
+ created_at: Optional[datetime]
91
+ last_active_at: Optional[datetime]
92
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
93
+
94
+ @classmethod
95
+ def from_api(cls, body: Mapping[str, Any]) -> Account:
96
+ summary = _map(body.get("summary"))
97
+ return cls(
98
+ login=_str(body, "login"),
99
+ email=_str(body, "email"),
100
+ plan=_str(body, "plan"),
101
+ plan_expires_at=_when(body, "planExpiresAt", "plan_expires_at"),
102
+ plan_auto_renew=_bool(body, "planAutoRenew", "plan_auto_renew"),
103
+ subscription_active=_bool(body, "subscriptionActive", "subscription_active"),
104
+ total_spent=_money(summary, "totalSpent", "total_spent"),
105
+ total_orders=_int(summary, "totalOrders", "total_orders"),
106
+ created_at=_when(body, "createdAt", "created_at"),
107
+ last_active_at=_when(body, "lastActiveAt", "last_active_at"),
108
+ raw=dict(body),
109
+ )
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class InputField:
114
+ key: str
115
+ label: Optional[str] = None
116
+ type: str = "text"
117
+ required: bool = True
118
+ options: tuple[Any, ...] = ()
119
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
120
+
121
+ @classmethod
122
+ def from_api(cls, body: Mapping[str, Any]) -> InputField:
123
+ required = body.get("required")
124
+ return cls(
125
+ key=_str(body, "key", "name") or "",
126
+ label=_str(body, "label"),
127
+ type=_str(body, "type") or "text",
128
+ required=required if isinstance(required, bool) else True,
129
+ options=tuple(_list(body.get("options"))),
130
+ raw=dict(body),
131
+ )
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class Category:
136
+ category_id: str
137
+ name: str
138
+ note: Optional[str] = None
139
+ image_url: Optional[str] = None
140
+ kind: Optional[str] = None
141
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
142
+
143
+ @classmethod
144
+ def from_api(cls, body: Mapping[str, Any], kind: Optional[str] = None) -> Category:
145
+ return cls(
146
+ category_id=_str(body, "category_id", "id") or "",
147
+ name=_str(body, "name") or "",
148
+ note=_str(body, "note"),
149
+ image_url=_str(body, "imageurl", "image_url", "image"),
150
+ kind=kind,
151
+ raw=dict(body),
152
+ )
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class CategoryPage:
157
+ items: tuple[Category, ...]
158
+ total: Optional[int]
159
+ limit: Optional[int]
160
+ next_cursor: Optional[str]
161
+ has_more: bool
162
+
163
+ @classmethod
164
+ def from_api(cls, body: Mapping[str, Any]) -> CategoryPage:
165
+ meta = _map(body.get("meta"))
166
+ kind = _str(body, "kind")
167
+ cursor = _str(meta, "next_cursor")
168
+ return cls(
169
+ items=tuple(Category.from_api(i, kind) for i in _list(body.get("items")) if isinstance(i, Mapping)),
170
+ total=_int(meta, "total"),
171
+ limit=_int(meta, "limit"),
172
+ next_cursor=cursor,
173
+ has_more=bool(meta.get("has_more")) and cursor is not None,
174
+ )
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class Offer:
179
+ id: str
180
+ name: str
181
+ price: Optional[Decimal]
182
+ stock: Optional[int] = None
183
+ min_quantity: int = 1
184
+ max_quantity: Optional[int] = None
185
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
186
+
187
+ @property
188
+ def in_stock(self) -> bool:
189
+ return self.stock is None or self.stock > 0
190
+
191
+ @classmethod
192
+ def from_api(cls, body: Mapping[str, Any]) -> Offer:
193
+ return cls(
194
+ id=_str(body, "offer_id", "card_id", "id") or "",
195
+ name=_str(body, "name") or "",
196
+ price=_money(body, "price_usd", "price"),
197
+ stock=_int(body, "stock"),
198
+ min_quantity=_int(body, "min_order_quantity", "min_quantity") or 1,
199
+ max_quantity=_int(body, "max_order_quantity", "max_quantity"),
200
+ raw=dict(body),
201
+ )
202
+
203
+
204
+ @dataclass(frozen=True)
205
+ class Offers:
206
+ category_id: str
207
+ name: str
208
+ kind: Optional[str]
209
+ offers: tuple[Offer, ...]
210
+ fields: tuple[InputField, ...] = ()
211
+ note: Optional[str] = None
212
+ image_url: Optional[str] = None
213
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
214
+
215
+ def get(self, offer_id: str) -> Optional[Offer]:
216
+ return next((offer for offer in self.offers if offer.id == offer_id), None)
217
+
218
+ @classmethod
219
+ def from_api(cls, body: Mapping[str, Any]) -> Offers:
220
+ return cls(
221
+ category_id=_str(body, "category_id") or "",
222
+ name=_str(body, "name") or "",
223
+ kind=_str(body, "kind"),
224
+ offers=tuple(Offer.from_api(item) for item in _list(body.get("offers")) if isinstance(item, Mapping)),
225
+ fields=tuple(InputField.from_api(item) for item in _list(body.get("fields")) if isinstance(item, Mapping)),
226
+ note=_str(body, "note"),
227
+ image_url=_str(body, "imageurl", "image_url", "image"),
228
+ raw=dict(body),
229
+ )
230
+
231
+
232
+ @dataclass(frozen=True)
233
+ class ValidationGame:
234
+ category_id: str
235
+ name: str
236
+ fields: tuple[InputField, ...]
237
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
238
+
239
+ @classmethod
240
+ def from_api(cls, body: Mapping[str, Any]) -> ValidationGame:
241
+ return cls(
242
+ category_id=_str(body, "category_id") or "",
243
+ name=_str(body, "name") or "",
244
+ fields=tuple(InputField.from_api(item) for item in _list(body.get("fields")) if isinstance(item, Mapping)),
245
+ raw=dict(body),
246
+ )
247
+
248
+
249
+ @dataclass(frozen=True)
250
+ class PlayerCheck:
251
+ valid: Optional[bool]
252
+ category_id: Optional[str] = None
253
+ player_id: Optional[str] = None
254
+ player_name: Optional[str] = None
255
+ region: Optional[str] = None
256
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
257
+
258
+ @classmethod
259
+ def from_api(cls, body: Mapping[str, Any]) -> PlayerCheck:
260
+ return cls(
261
+ valid=_bool(body, "valid"),
262
+ category_id=_str(body, "category_id"),
263
+ player_id=_str(body, "player_id"),
264
+ player_name=_str(body, "player_name"),
265
+ region=_str(body, "region"),
266
+ raw=dict(body),
267
+ )
268
+
269
+
270
+ @dataclass(frozen=True)
271
+ class SteamLogin:
272
+ can_refill: Optional[bool]
273
+ unverified: bool = False
274
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
275
+
276
+ @property
277
+ def valid(self) -> Optional[bool]:
278
+ if self.can_refill:
279
+ return True
280
+ return None if self.unverified or self.can_refill is None else False
281
+
282
+ @classmethod
283
+ def from_api(cls, body: Mapping[str, Any]) -> SteamLogin:
284
+ return cls(_bool(body, "can_refill", "canRefill"), bool(_bool(body, "unverified")), dict(body))
285
+
286
+
287
+ @dataclass(frozen=True)
288
+ class SteamRates:
289
+ base: str
290
+ rates: Mapping[str, Decimal]
291
+ updated_at: Optional[datetime] = None
292
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
293
+
294
+ def convert(self, usd: Decimal, currency: str) -> Optional[Decimal]:
295
+ rate = self.rates.get(str(currency).upper())
296
+ return None if rate is None else usd * rate
297
+
298
+ @classmethod
299
+ def from_api(cls, body: Mapping[str, Any]) -> SteamRates:
300
+ rates = {str(code).upper(): value for code, raw in _map(body.get("rates")).items()
301
+ if (value := parse(raw)) is not None}
302
+ return cls(_str(body, "base") or "USD", rates, _when(body, "updated_at", "updatedAt"), dict(body))
303
+
304
+
305
+ @dataclass(frozen=True)
306
+ class StarsQuote:
307
+ price_per_star: Decimal
308
+ min_amount: int
309
+ max_amount: int
310
+ rates_updated_at: Optional[datetime] = None
311
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
312
+
313
+ def price(self, quantity: int) -> Decimal:
314
+ return self.price_per_star * quantity
315
+
316
+ @classmethod
317
+ def from_api(cls, body: Mapping[str, Any]) -> StarsQuote:
318
+ return cls(
319
+ price_per_star=_money(body, "price_per_star") or Decimal("0"),
320
+ min_amount=_int(body, "min_amount") or 50,
321
+ max_amount=_int(body, "max_amount") or 100000,
322
+ rates_updated_at=_when(body, "rates_updated_at"),
323
+ raw=dict(body),
324
+ )
325
+
326
+
327
+ @dataclass(frozen=True)
328
+ class PremiumPlan:
329
+ months: int
330
+ price: Decimal
331
+
332
+
333
+ @dataclass(frozen=True)
334
+ class PremiumQuote:
335
+ plans: tuple[PremiumPlan, ...]
336
+ rates_updated_at: Optional[datetime] = None
337
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
338
+
339
+ def plan(self, months: int) -> Optional[PremiumPlan]:
340
+ return next((plan for plan in self.plans if plan.months == months), None)
341
+
342
+ @classmethod
343
+ def from_api(cls, body: Mapping[str, Any]) -> PremiumQuote:
344
+ plans = []
345
+ for item in _list(body.get("plans")):
346
+ if not isinstance(item, Mapping):
347
+ continue
348
+ months, price = _int(item, "months"), _money(item, "price_usd", "price")
349
+ if months is not None and price is not None:
350
+ plans.append(PremiumPlan(months, price))
351
+ return cls(tuple(plans), _when(body, "rates_updated_at"), dict(body))
352
+
353
+
354
+ @dataclass(frozen=True)
355
+ class StatusChange:
356
+ status: OrderStatus
357
+ at: Optional[datetime]
358
+
359
+
360
+ @dataclass(frozen=True)
361
+ class Order:
362
+ id: str
363
+ kind: OrderKind
364
+ status: OrderStatus
365
+ title: Optional[str] = None
366
+ category_id: Optional[str] = None
367
+ category_name: Optional[str] = None
368
+ offer_id: Optional[str] = None
369
+ offer_name: Optional[str] = None
370
+ quantity: Optional[int] = None
371
+ fields: Mapping[str, Any] = field(default_factory=dict)
372
+ unit_price: Optional[Decimal] = None
373
+ total: Optional[Decimal] = None
374
+ codes: tuple[str, ...] = ()
375
+ fail_reason: Optional[str] = None
376
+ history: tuple[StatusChange, ...] = ()
377
+ created_at: Optional[datetime] = None
378
+ completed_at: Optional[datetime] = None
379
+ idempotency_key: Optional[str] = None
380
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
381
+
382
+ @property
383
+ def delivered(self) -> bool:
384
+ return self.status.delivered
385
+
386
+ @property
387
+ def failed(self) -> bool:
388
+ return self.status.failed
389
+
390
+ @property
391
+ def in_flight(self) -> bool:
392
+ return self.status.in_flight
393
+
394
+ @property
395
+ def awaiting_codes(self) -> bool:
396
+ return self.kind is OrderKind.GIFT_CARD and self.delivered and not self.codes
397
+
398
+ def with_key(self, key: Optional[str]) -> Order:
399
+ return replace(self, idempotency_key=key)
400
+
401
+ @classmethod
402
+ def from_api(cls, body: Mapping[str, Any]) -> Order:
403
+ order = _map(body.get("order")) or body
404
+ history = tuple(
405
+ StatusChange(OrderStatus.of(item.get("status")), _when(item, "at"))
406
+ for item in _list(_pick(order, "status_history", "statusHistory"))
407
+ if isinstance(item, Mapping)
408
+ )
409
+ return cls(
410
+ id=_str(order, "id", "order_id") or "",
411
+ kind=OrderKind.of(order.get("kind")),
412
+ status=OrderStatus.of(order.get("status")),
413
+ title=_str(order, "title"),
414
+ category_id=_str(order, "category_id"),
415
+ category_name=_str(order, "category_name"),
416
+ offer_id=_str(order, "offer_id", "card_id"),
417
+ offer_name=_str(order, "offer_name"),
418
+ quantity=_int(order, "quantity", "stars_quantity", "starsQuantity", "months"),
419
+ fields=dict(_map(order.get("fields"))),
420
+ unit_price=_money(order, "price_usd_per_unit", "price_usd"),
421
+ total=_money(order, "total_usd", "chargedUsd", "charged_usd", "amountUsd"),
422
+ codes=tuple(card_code(card) for card in _list(order.get("cards")) if card not in (None, "")),
423
+ fail_reason=_str(order, "fail_reason", "failReason"),
424
+ history=history,
425
+ created_at=_when(order, "created_at", "createdAt"),
426
+ completed_at=_when(order, "completed_at", "completedAt"),
427
+ raw=dict(order),
428
+ )
429
+
430
+
431
+ @dataclass(frozen=True)
432
+ class OrderPage:
433
+ items: tuple[Order, ...]
434
+ total: Optional[int]
435
+ page: int
436
+ limit: int
437
+
438
+ @property
439
+ def has_more(self) -> bool:
440
+ return self.total is not None and self.page * self.limit < self.total
441
+
442
+ @classmethod
443
+ def from_api(cls, body: Mapping[str, Any]) -> OrderPage:
444
+ return cls(
445
+ items=tuple(Order.from_api(item) for item in _list(body.get("items")) if isinstance(item, Mapping)),
446
+ total=_int(body, "total"),
447
+ page=_int(body, "page") or 1,
448
+ limit=_int(body, "limit") or 20,
449
+ )
450
+
451
+
452
+ @dataclass(frozen=True)
453
+ class Webhook:
454
+ url: Optional[str]
455
+ enabled: bool
456
+ consecutive_failures: int = 0
457
+ last_success_at: Optional[datetime] = None
458
+ last_failure_at: Optional[datetime] = None
459
+ last_failure_reason: Optional[str] = None
460
+ created_at: Optional[datetime] = None
461
+ updated_at: Optional[datetime] = None
462
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
463
+
464
+ @classmethod
465
+ def from_api(cls, body: Mapping[str, Any]) -> Webhook:
466
+ hook = _map(body.get("webhook")) or body
467
+ return cls(
468
+ url=_str(hook, "url"),
469
+ enabled=bool(_bool(hook, "enabled")),
470
+ consecutive_failures=_int(hook, "consecutive_failures") or 0,
471
+ last_success_at=_when(hook, "last_success_at"),
472
+ last_failure_at=_when(hook, "last_failure_at"),
473
+ last_failure_reason=_str(hook, "last_failure_reason"),
474
+ created_at=_when(hook, "created_at"),
475
+ updated_at=_when(hook, "updated_at"),
476
+ raw=dict(hook),
477
+ )
478
+
479
+
480
+ @dataclass(frozen=True)
481
+ class WebhookEvent:
482
+ order_id: Optional[str]
483
+ status: OrderStatus
484
+ order: Optional[Order]
485
+ merchant_txn_id: Optional[str] = None
486
+ event: Optional[str] = None
487
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
488
+
489
+ @property
490
+ def needs_lookup(self) -> bool:
491
+ return self.order_id is not None and (self.status is OrderStatus.UNKNOWN or self.order is None)
492
+
493
+ @classmethod
494
+ def from_api(cls, body: Mapping[str, Any]) -> WebhookEvent:
495
+ order = _map(body.get("order")) or _map(body.get("data")) or body
496
+ if isinstance(order.get("order"), Mapping):
497
+ order = order["order"]
498
+ order_id = _str(order, "id", "order_id") or _str(body, "order_id", "id")
499
+ status = OrderStatus.of(_pick(order, "status") or body.get("status"))
500
+ parsed = Order.from_api(order) if _str(order, "id", "order_id") and _pick(order, "status") else None
501
+ return cls(
502
+ order_id=order_id,
503
+ status=status,
504
+ order=parsed,
505
+ merchant_txn_id=_str(order, "merchant_txn_id") or _str(body, "merchant_txn_id"),
506
+ event=_str(body, "event", "type"),
507
+ raw=dict(body),
508
+ )
509
+
510
+
511
+ @dataclass(frozen=True)
512
+ class RateLimit:
513
+ limit: Optional[int]
514
+ remaining: Optional[int]
515
+ reset: Optional[float]
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from decimal import Decimal, InvalidOperation
4
+ from typing import Any, Optional, Union
5
+
6
+ from .errors import InvalidRequestError
7
+ from .messages import text
8
+
9
+ Amount = Union[Decimal, int, float, str]
10
+
11
+ CENT = Decimal("0.01")
12
+
13
+
14
+ def parse(value: Any) -> Optional[Decimal]:
15
+ if value is None or value == "" or isinstance(value, bool):
16
+ return None
17
+ try:
18
+ result = Decimal(str(value))
19
+ except (InvalidOperation, ValueError):
20
+ return None
21
+ return result if result.is_finite() else None
22
+
23
+
24
+ def amount(value: Amount, field: str = "amount") -> Decimal:
25
+ if isinstance(value, bool) or not isinstance(value, (Decimal, int, float, str)):
26
+ raise InvalidRequestError(text("value.amount_type", field=field, value=value))
27
+ try:
28
+ result = Decimal(str(value).strip())
29
+ except (InvalidOperation, ValueError):
30
+ raise InvalidRequestError(text("value.amount_type", field=field, value=value)) from None
31
+ if not result.is_finite():
32
+ raise InvalidRequestError(text("value.amount_finite", field=field, value=value))
33
+ if result <= 0:
34
+ raise InvalidRequestError(text("value.amount_positive", field=field, value=value))
35
+ if result != result.quantize(CENT):
36
+ raise InvalidRequestError(text("value.amount_scale", field=field, value=value))
37
+ return result
38
+
39
+
40
+ def wire(value: Decimal) -> str:
41
+ return format(value.quantize(CENT), "f")
File without changes