meterflow 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.
meterflow/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """Official Python SDK for MeterFlow — usage-based billing, credit management, and metering.
2
+
3
+ from meterflow import MeterFlow
4
+
5
+ client = MeterFlow(api_key="mf_live_...")
6
+ client.credits.grant({"customer_external_id": "customer_123", "amount": 5000, "metadata": {}})
7
+ """
8
+
9
+ from ._version import __version__
10
+ from .client import AsyncMeterFlow, ClientOptions, MeterFlow
11
+ from .errors import (
12
+ AuthError,
13
+ ConflictError,
14
+ InsufficientCreditsError,
15
+ MeterFlowError,
16
+ NotFoundError,
17
+ PayloadTooLargeError,
18
+ RateLimitError,
19
+ ServerError,
20
+ ValidationError,
21
+ ValidationFieldError,
22
+ )
23
+ from .resources.usage import MAX_BATCH_EVENTS
24
+ from .webhook import verify_webhook
25
+
26
+ __all__ = [
27
+ "__version__",
28
+ "MeterFlow",
29
+ "AsyncMeterFlow",
30
+ "ClientOptions",
31
+ "MeterFlowError",
32
+ "AuthError",
33
+ "NotFoundError",
34
+ "InsufficientCreditsError",
35
+ "ConflictError",
36
+ "PayloadTooLargeError",
37
+ "ValidationError",
38
+ "ValidationFieldError",
39
+ "RateLimitError",
40
+ "ServerError",
41
+ "MAX_BATCH_EVENTS",
42
+ "verify_webhook",
43
+ ]
meterflow/_http.py ADDED
@@ -0,0 +1,160 @@
1
+ """The HTTP layer both clients share: URL/header building, error-body parsing, status → error mapping.
2
+
3
+ Only the *waiting* differs between sync and async, so the request loops live in ``client.py`` and
4
+ everything they call lives here, once. Mirrors ``sdks/node/src/utils/http.ts``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+ from urllib.parse import quote
15
+
16
+ import httpx
17
+
18
+ from ._version import __version__
19
+ from .errors import (
20
+ AuthError,
21
+ ConflictError,
22
+ InsufficientCreditsError,
23
+ MeterFlowError,
24
+ NotFoundError,
25
+ PayloadTooLargeError,
26
+ RateLimitError,
27
+ ServerError,
28
+ ValidationError,
29
+ ValidationFieldError,
30
+ )
31
+
32
+ USER_AGENT = f"meterflow-python/{__version__}"
33
+
34
+ QueryValue = str | int | float | bool | None
35
+
36
+ # Indirections so tests can make the retry loop instantaneous without touching the clock.
37
+ _sleep = time.sleep
38
+
39
+
40
+ async def _async_sleep(seconds: float) -> None:
41
+ await asyncio.sleep(seconds)
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class RequestSpec:
46
+ """One API call, described by a resource method and executed by a client."""
47
+
48
+ method: str
49
+ path: str
50
+ body: Any = None
51
+ query: dict[str, QueryValue] = field(default_factory=dict)
52
+ idempotency_key: str | None = None
53
+ timeout: float | None = None
54
+
55
+
56
+ def encode_path_segment(value: str) -> str:
57
+ """Customer ids are caller-chosen strings (emails, ``user/42``…) — they must never split the path."""
58
+ return quote(value, safe="")
59
+
60
+
61
+ def build_headers(api_key: str, spec: RequestSpec) -> dict[str, str]:
62
+ headers = {"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT, "Accept": "application/json"}
63
+ if spec.body is not None:
64
+ headers["Content-Type"] = "application/json"
65
+ if spec.idempotency_key:
66
+ headers["Idempotency-Key"] = spec.idempotency_key
67
+ return headers
68
+
69
+
70
+ def build_params(query: dict[str, QueryValue]) -> dict[str, str]:
71
+ """Unset (``None``) filters are simply not sent; booleans go as ``true``/``false`` like the Node SDK."""
72
+ params: dict[str, str] = {}
73
+ for key, value in query.items():
74
+ if value is None:
75
+ continue
76
+ params[key] = ("true" if value else "false") if isinstance(value, bool) else str(value)
77
+ return params
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class ParsedError:
82
+ message: str
83
+ fields: list[ValidationFieldError]
84
+
85
+
86
+ def _is_field_error(value: object) -> bool:
87
+ return isinstance(value, dict) and isinstance(value.get("field"), str) and isinstance(value.get("message"), str)
88
+
89
+
90
+ def parse_error_body(response: httpx.Response) -> ParsedError:
91
+ """The API answers every error as ``{"error": {"code", "message", "fields"?}}`` (``app/api/src/exceptions.py``).
92
+
93
+ ``detail`` is FastAPI's default shape, kept as a fallback so an unexpected passthrough still
94
+ yields a readable message rather than a JSON blob.
95
+ """
96
+ try:
97
+ payload = response.json()
98
+ except (json.JSONDecodeError, ValueError):
99
+ return ParsedError(response.reason_phrase or f"HTTP {response.status_code}", [])
100
+ if not isinstance(payload, dict):
101
+ return ParsedError(json.dumps(payload), [])
102
+ error = payload.get("error")
103
+ if isinstance(error, dict) and isinstance(error.get("message"), str):
104
+ raw_fields = error.get("fields")
105
+ fields: list[ValidationFieldError] = (
106
+ [{"field": item["field"], "message": item["message"]} for item in raw_fields if _is_field_error(item)]
107
+ if isinstance(raw_fields, list)
108
+ else []
109
+ )
110
+ # A 422's headline is always "Validation failed"; the field list is what the caller needs.
111
+ detail = "; ".join(f"{item['field']}: {item['message']}" for item in fields)
112
+ message = f"{error['message']}: {detail}" if detail else error["message"]
113
+ return ParsedError(message, fields)
114
+ detail_value = payload.get("detail")
115
+ if isinstance(detail_value, str):
116
+ return ParsedError(detail_value, [])
117
+ return ParsedError(json.dumps(payload), [])
118
+
119
+
120
+ def map_response_error(response: httpx.Response) -> MeterFlowError:
121
+ status = response.status_code
122
+ parsed = parse_error_body(response)
123
+ request_id = response.headers.get("x-request-id")
124
+ message, fields = parsed.message, parsed.fields
125
+ if status in (401, 403):
126
+ return AuthError(message, request_id=request_id, status_code=status)
127
+ if status == 402:
128
+ return InsufficientCreditsError(message, request_id=request_id)
129
+ if status == 404:
130
+ return NotFoundError(message, request_id=request_id)
131
+ if status == 409:
132
+ return ConflictError(message, request_id=request_id)
133
+ if status == 413:
134
+ return PayloadTooLargeError(message, request_id=request_id)
135
+ if status == 422:
136
+ return ValidationError(message, request_id=request_id, fields=fields)
137
+ if status == 429:
138
+ retry_after_raw = response.headers.get("retry-after")
139
+ retry_after = int(retry_after_raw) if retry_after_raw is not None and retry_after_raw.strip().isdigit() else None
140
+ return RateLimitError(message, retry_after=retry_after, request_id=request_id)
141
+ if status >= 500:
142
+ return ServerError(message, request_id=request_id, status_code=status)
143
+ return MeterFlowError(message, "request_error", retryable=False, request_id=request_id, status_code=status)
144
+
145
+
146
+ def decode_response(response: httpx.Response) -> Any:
147
+ """2xx → parsed JSON; ``204`` / an empty body → ``None`` (``subscriptions.delete``)."""
148
+ if response.status_code == 204 or not response.content:
149
+ return None
150
+ return response.json()
151
+
152
+
153
+ def request_kwargs(api_key: str, spec: RequestSpec) -> dict[str, Any]:
154
+ """Everything ``httpx.Client.request`` / ``AsyncClient.request`` needs beyond the method and path."""
155
+ kwargs: dict[str, Any] = {"headers": build_headers(api_key, spec), "params": build_params(spec.query)}
156
+ if spec.body is not None:
157
+ kwargs["content"] = json.dumps(spec.body).encode("utf-8")
158
+ if spec.timeout is not None:
159
+ kwargs["timeout"] = spec.timeout
160
+ return kwargs
meterflow/_retry.py ADDED
@@ -0,0 +1,38 @@
1
+ """Retry policy shared by the sync and async clients.
2
+
3
+ The policy only *decides* — how long to wait before attempt N+1, or that the caller should give up.
4
+ The clients do the sleeping (``time.sleep`` vs ``asyncio.sleep``), so the backoff maths lives once.
5
+ Mirrors the Node SDK: exponential base (1 s × factor^attempt, capped), ±25 % jitter, and a 429's
6
+ ``Retry-After`` taken verbatim instead of the computed delay.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import random
12
+ from dataclasses import dataclass
13
+
14
+ from .errors import MeterFlowError, RateLimitError
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class RetryPolicy:
19
+ retries: int = 3
20
+ factor: float = 2.0
21
+ jitter: bool = True
22
+ max_delay_seconds: float = 10.0
23
+
24
+ def should_retry(self, error: BaseException) -> bool:
25
+ """API errors say for themselves whether a retry can help; anything else is a transport failure and can."""
26
+ if isinstance(error, MeterFlowError):
27
+ return error.retryable
28
+ return True
29
+
30
+ def delay_before_retry(self, attempt: int, error: BaseException) -> float | None:
31
+ """Seconds to wait before retrying ``attempt`` (0-based), or ``None`` when the caller must re-raise."""
32
+ is_last = attempt >= self.retries
33
+ if is_last or not self.should_retry(error):
34
+ return None
35
+ if isinstance(error, RateLimitError) and error.retry_after is not None:
36
+ return float(error.retry_after)
37
+ base = min(1.0 * (self.factor**attempt), self.max_delay_seconds)
38
+ return base * (0.75 + random.random() * 0.5) if self.jitter else base # noqa: S311 — jitter, not security
meterflow/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ # The one place the version lives. pyproject.toml reads it (hatch dynamic version) and the
2
+ # User-Agent header reports it, so a release bump edits this file and nothing else.
3
+ __version__ = "0.1.0"
meterflow/client.py ADDED
@@ -0,0 +1,148 @@
1
+ """``MeterFlow`` (sync) and ``AsyncMeterFlow`` — same options, same resources, same errors.
2
+
3
+ Resources describe a call as a ``RequestSpec``; the client executes it with retries. Everything that
4
+ does not depend on *how* you wait lives in ``_http.py`` and ``_retry.py``, so the two clients are
5
+ thin and cannot drift apart.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from itertools import count
12
+ from types import TracebackType
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from ._http import RequestSpec, _async_sleep, _sleep, decode_response, map_response_error, request_kwargs
18
+ from ._retry import RetryPolicy
19
+ from .resources.credits import AsyncCreditsResource, CreditsResource
20
+ from .resources.entitlements import AsyncEntitlementsResource, EntitlementsResource
21
+ from .resources.plans import AsyncPlansResource, PlansResource
22
+ from .resources.subscriptions import AsyncSubscriptionsResource, SubscriptionsResource
23
+ from .resources.usage import AsyncUsageResource, UsageResource
24
+
25
+ DEFAULT_BASE_URL = "https://api.meter-flow.com/api/v1"
26
+ DEFAULT_TIMEOUT_SECONDS = 30.0
27
+ DEFAULT_RETRIES = 3
28
+ KEY_PREFIXES = ("mf_live_", "mf_test_")
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ClientOptions:
33
+ """The resolved configuration — what the Node SDK exposes as ``client.options``."""
34
+
35
+ api_key: str
36
+ base_url: str
37
+ timeout: float
38
+ retries: int
39
+
40
+
41
+ class _BaseClient:
42
+ def __init__(self, api_key: str, base_url: str | None, timeout: float | None, retries: int | None) -> None:
43
+ if not api_key.startswith(KEY_PREFIXES):
44
+ raise ValueError("api_key must start with mf_live_ or mf_test_")
45
+ self.options = ClientOptions(
46
+ api_key=api_key,
47
+ base_url=(base_url or DEFAULT_BASE_URL).rstrip("/") + "/",
48
+ timeout=DEFAULT_TIMEOUT_SECONDS if timeout is None else timeout,
49
+ retries=DEFAULT_RETRIES if retries is None else retries,
50
+ )
51
+ self._retry = RetryPolicy(retries=self.options.retries)
52
+
53
+ def _kwargs(self, spec: RequestSpec) -> dict[str, Any]:
54
+ return request_kwargs(self.options.api_key, spec)
55
+
56
+
57
+ class MeterFlow(_BaseClient):
58
+ """Synchronous client.
59
+
60
+ ``transport`` injects an ``httpx`` transport (tests, proxies) — the Python counterpart of the
61
+ Node SDK's ``fetch`` option. Use it as a context manager or call ``close()`` to release connections.
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ api_key: str,
67
+ *,
68
+ base_url: str | None = None,
69
+ timeout: float | None = None,
70
+ retries: int | None = None,
71
+ transport: httpx.BaseTransport | None = None,
72
+ ) -> None:
73
+ super().__init__(api_key, base_url, timeout, retries)
74
+ self._http = httpx.Client(base_url=self.options.base_url, timeout=self.options.timeout, transport=transport)
75
+ self.credits = CreditsResource(self)
76
+ self.usage = UsageResource(self)
77
+ self.subscriptions = SubscriptionsResource(self)
78
+ self.plans = PlansResource(self)
79
+ self.entitlements = EntitlementsResource(self)
80
+
81
+ def request(self, spec: RequestSpec) -> Any:
82
+ """Internal — used by resource classes; not part of the public API."""
83
+ for attempt in count():
84
+ try:
85
+ response = self._http.request(spec.method, spec.path, **self._kwargs(spec))
86
+ if response.is_error:
87
+ raise map_response_error(response)
88
+ return decode_response(response)
89
+ except Exception as error:
90
+ delay = self._retry.delay_before_retry(attempt, error)
91
+ if delay is None:
92
+ raise
93
+ _sleep(delay)
94
+ raise AssertionError("unreachable") # pragma: no cover — `count()` never ends
95
+
96
+ def close(self) -> None:
97
+ self._http.close()
98
+
99
+ def __enter__(self) -> MeterFlow:
100
+ return self
101
+
102
+ def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None) -> None:
103
+ self.close()
104
+
105
+
106
+ class AsyncMeterFlow(_BaseClient):
107
+ """Asynchronous client — identical surface, every method awaitable. ``async with`` or ``aclose()``."""
108
+
109
+ def __init__(
110
+ self,
111
+ api_key: str,
112
+ *,
113
+ base_url: str | None = None,
114
+ timeout: float | None = None,
115
+ retries: int | None = None,
116
+ transport: httpx.AsyncBaseTransport | None = None,
117
+ ) -> None:
118
+ super().__init__(api_key, base_url, timeout, retries)
119
+ self._http = httpx.AsyncClient(base_url=self.options.base_url, timeout=self.options.timeout, transport=transport)
120
+ self.credits = AsyncCreditsResource(self)
121
+ self.usage = AsyncUsageResource(self)
122
+ self.subscriptions = AsyncSubscriptionsResource(self)
123
+ self.plans = AsyncPlansResource(self)
124
+ self.entitlements = AsyncEntitlementsResource(self)
125
+
126
+ async def request(self, spec: RequestSpec) -> Any:
127
+ """Internal — used by resource classes; not part of the public API."""
128
+ for attempt in count():
129
+ try:
130
+ response = await self._http.request(spec.method, spec.path, **self._kwargs(spec))
131
+ if response.is_error:
132
+ raise map_response_error(response)
133
+ return decode_response(response)
134
+ except Exception as error:
135
+ delay = self._retry.delay_before_retry(attempt, error)
136
+ if delay is None:
137
+ raise
138
+ await _async_sleep(delay)
139
+ raise AssertionError("unreachable") # pragma: no cover — `count()` never ends
140
+
141
+ async def aclose(self) -> None:
142
+ await self._http.aclose()
143
+
144
+ async def __aenter__(self) -> AsyncMeterFlow:
145
+ return self
146
+
147
+ async def __aexit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None) -> None:
148
+ await self.aclose()
meterflow/errors.py ADDED
@@ -0,0 +1,98 @@
1
+ """Typed error hierarchy — one class per HTTP outcome, mirroring the Node SDK one-to-one.
2
+
3
+ Every error carries the API's ``X-Request-ID`` (``request_id``) so a support ticket can point at the
4
+ exact request, plus ``status_code``, ``error_type`` and ``retryable`` for structured logging.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TypedDict
10
+
11
+
12
+ class ValidationFieldError(TypedDict):
13
+ """One field the API rejected, as returned in a 422's ``error.fields``."""
14
+
15
+ field: str
16
+ message: str
17
+
18
+
19
+ class MeterFlowError(Exception):
20
+ def __init__(
21
+ self,
22
+ message: str,
23
+ error_type: str = "request_error",
24
+ *,
25
+ retryable: bool = False,
26
+ request_id: str | None = None,
27
+ status_code: int | None = None,
28
+ ) -> None:
29
+ super().__init__(message)
30
+ self.message = message
31
+ self.error_type = error_type
32
+ self.retryable = retryable
33
+ self.request_id = request_id
34
+ self.status_code = status_code
35
+
36
+ def __str__(self) -> str:
37
+ return self.message
38
+
39
+
40
+ class AuthError(MeterFlowError):
41
+ """401 / 403 — the key is missing, invalid, revoked or not allowed to do this."""
42
+
43
+ def __init__(self, message: str, *, request_id: str | None = None, status_code: int = 401) -> None:
44
+ super().__init__(message, "auth_error", retryable=False, request_id=request_id, status_code=status_code)
45
+
46
+
47
+ class NotFoundError(MeterFlowError):
48
+ """404 — no such customer, plan, subscription… or an unknown feature key (a typo, not a plan)."""
49
+
50
+ def __init__(self, message: str, *, request_id: str | None = None) -> None:
51
+ super().__init__(message, "not_found", retryable=False, request_id=request_id, status_code=404)
52
+
53
+
54
+ class InsufficientCreditsError(MeterFlowError):
55
+ """402 — a deduction the balance cannot cover. A business outcome, not a failure: prompt a top-up."""
56
+
57
+ def __init__(self, message: str, *, request_id: str | None = None) -> None:
58
+ super().__init__(message, "insufficient_credits", retryable=False, request_id=request_id, status_code=402)
59
+
60
+
61
+ class ConflictError(MeterFlowError):
62
+ """409 — the write clashes with current state (e.g. the project is deactivated)."""
63
+
64
+ def __init__(self, message: str, *, request_id: str | None = None) -> None:
65
+ super().__init__(message, "conflict", retryable=False, request_id=request_id, status_code=409)
66
+
67
+
68
+ class ValidationError(MeterFlowError):
69
+ """422 — the API rejected the payload. ``fields`` holds the per-field detail (empty when none was sent)."""
70
+
71
+ def __init__(self, message: str, *, request_id: str | None = None, fields: list[ValidationFieldError] | None = None) -> None:
72
+ super().__init__(message, "validation_error", retryable=False, request_id=request_id, status_code=422)
73
+ self.fields: list[ValidationFieldError] = list(fields or [])
74
+
75
+
76
+ class PayloadTooLargeError(MeterFlowError):
77
+ """413 — well-formed but too big; today only ``usage.record_batch`` above ``MAX_BATCH_EVENTS``.
78
+
79
+ Never retried: the same payload would be refused again. Split it at the call site.
80
+ """
81
+
82
+ def __init__(self, message: str, *, request_id: str | None = None) -> None:
83
+ super().__init__(message, "payload_too_large", retryable=False, request_id=request_id, status_code=413)
84
+
85
+
86
+ class RateLimitError(MeterFlowError):
87
+ """429 — retried automatically after the server's ``Retry-After`` (seconds, exposed as ``retry_after``)."""
88
+
89
+ def __init__(self, message: str, *, retry_after: int | None = None, request_id: str | None = None) -> None:
90
+ super().__init__(message, "rate_limit", retryable=True, request_id=request_id, status_code=429)
91
+ self.retry_after = retry_after
92
+
93
+
94
+ class ServerError(MeterFlowError):
95
+ """5xx — MeterFlow's side; retried automatically with backoff."""
96
+
97
+ def __init__(self, message: str, *, request_id: str | None = None, status_code: int = 500) -> None:
98
+ super().__init__(message, "server_error", retryable=True, request_id=request_id, status_code=status_code)
meterflow/py.typed ADDED
File without changes
@@ -0,0 +1 @@
1
+ """The five SDK-facing resources, each in a sync and an async flavour sharing one set of request specs."""
@@ -0,0 +1,77 @@
1
+ """Credits — an append-only ledger per customer: grant up, deduct down, never edited in place."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from .._http import RequestSpec, encode_path_segment
8
+ from ..types import CreditBalanceResponse, CreditDeductRequest, CreditGrantRequest, CreditTransactionResponse
9
+
10
+ if TYPE_CHECKING:
11
+ from ..client import AsyncMeterFlow, MeterFlow
12
+
13
+
14
+ def grant_spec(body: CreditGrantRequest, idempotency_key: str | None) -> RequestSpec:
15
+ return RequestSpec("POST", "credits/grant", body=body, idempotency_key=idempotency_key)
16
+
17
+
18
+ def deduct_spec(body: CreditDeductRequest, idempotency_key: str | None) -> RequestSpec:
19
+ return RequestSpec("POST", "credits/deduct", body=body, idempotency_key=idempotency_key)
20
+
21
+
22
+ def balance_spec(customer_id: str) -> RequestSpec:
23
+ return RequestSpec("GET", f"credits/{encode_path_segment(customer_id)}/balance")
24
+
25
+
26
+ def transactions_spec(customer_id: str) -> RequestSpec:
27
+ return RequestSpec("GET", f"credits/{encode_path_segment(customer_id)}/transactions")
28
+
29
+
30
+ class CreditsResource:
31
+ def __init__(self, client: MeterFlow) -> None:
32
+ self._client = client
33
+
34
+ def grant(self, body: CreditGrantRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
35
+ """Add credits (plan renewals, top-ups, goodwill). Returns the ledger line."""
36
+ result: CreditTransactionResponse = self._client.request(grant_spec(body, idempotency_key))
37
+ return result
38
+
39
+ def deduct(self, body: CreditDeductRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
40
+ """Remove credits. Raises ``InsufficientCreditsError`` (402) rather than take the customer below zero."""
41
+ result: CreditTransactionResponse = self._client.request(deduct_spec(body, idempotency_key))
42
+ return result
43
+
44
+ def balance(self, customer_id: str) -> CreditBalanceResponse:
45
+ """The customer's current position — amounts are decimal strings."""
46
+ result: CreditBalanceResponse = self._client.request(balance_spec(customer_id))
47
+ return result
48
+
49
+ def transactions(self, customer_id: str) -> list[CreditTransactionResponse]:
50
+ """The full history, newest first."""
51
+ result: list[CreditTransactionResponse] = self._client.request(transactions_spec(customer_id))
52
+ return result
53
+
54
+
55
+ class AsyncCreditsResource:
56
+ def __init__(self, client: AsyncMeterFlow) -> None:
57
+ self._client = client
58
+
59
+ async def grant(self, body: CreditGrantRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
60
+ """Add credits (plan renewals, top-ups, goodwill). Returns the ledger line."""
61
+ result: CreditTransactionResponse = await self._client.request(grant_spec(body, idempotency_key))
62
+ return result
63
+
64
+ async def deduct(self, body: CreditDeductRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
65
+ """Remove credits. Raises ``InsufficientCreditsError`` (402) rather than take the customer below zero."""
66
+ result: CreditTransactionResponse = await self._client.request(deduct_spec(body, idempotency_key))
67
+ return result
68
+
69
+ async def balance(self, customer_id: str) -> CreditBalanceResponse:
70
+ """The customer's current position — amounts are decimal strings."""
71
+ result: CreditBalanceResponse = await self._client.request(balance_spec(customer_id))
72
+ return result
73
+
74
+ async def transactions(self, customer_id: str) -> list[CreditTransactionResponse]:
75
+ """The full history, newest first."""
76
+ result: list[CreditTransactionResponse] = await self._client.request(transactions_spec(customer_id))
77
+ return result
@@ -0,0 +1,60 @@
1
+ """Entitlements — ask BEFORE doing the work.
2
+
3
+ ``check()`` is where a hard limit says no; recording usage afterwards never blocks, so an app that
4
+ skips this call gets no enforcement at all — only an honest ledger.
5
+
6
+ ``allowed: False`` is a normal answer, not an error: the call returns and ``reason`` says why
7
+ (``no_subscription``, ``not_in_plan``, ``hard_limit_reached``, ``insufficient_credits``). A feature
8
+ key the project does not know at all is a 404 → ``NotFoundError``, because that is a typo, not a plan.
9
+
10
+ Both calls return ``Cache-Control: private, max-age=15`` — cache per customer for a few seconds on
11
+ hot paths rather than calling on every request.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING
17
+
18
+ from .._http import RequestSpec, encode_path_segment
19
+ from ..types import EntitlementResponse, EntitlementsResponse
20
+
21
+ if TYPE_CHECKING:
22
+ from ..client import AsyncMeterFlow, MeterFlow
23
+
24
+
25
+ def get_spec(customer_id: str) -> RequestSpec:
26
+ return RequestSpec("GET", f"entitlements/{encode_path_segment(customer_id)}")
27
+
28
+
29
+ def check_spec(customer_id: str, feature: str, quantity: int | None) -> RequestSpec:
30
+ return RequestSpec("GET", f"entitlements/{encode_path_segment(customer_id)}/check", query={"feature": feature, "quantity": quantity})
31
+
32
+
33
+ class EntitlementsResource:
34
+ def __init__(self, client: MeterFlow) -> None:
35
+ self._client = client
36
+
37
+ def get(self, customer_id: str) -> EntitlementsResponse:
38
+ """Every feature on the customer's plan, with this period's usage. Empty ``entitlements`` without a subscription."""
39
+ result: EntitlementsResponse = self._client.request(get_spec(customer_id))
40
+ return result
41
+
42
+ def check(self, customer_id: str, feature: str, *, quantity: int | None = None) -> EntitlementResponse:
43
+ """May this customer use ``feature`` (``quantity`` more units of it — "may they do 5 more?") right now?"""
44
+ result: EntitlementResponse = self._client.request(check_spec(customer_id, feature, quantity))
45
+ return result
46
+
47
+
48
+ class AsyncEntitlementsResource:
49
+ def __init__(self, client: AsyncMeterFlow) -> None:
50
+ self._client = client
51
+
52
+ async def get(self, customer_id: str) -> EntitlementsResponse:
53
+ """Every feature on the customer's plan, with this period's usage. Empty ``entitlements`` without a subscription."""
54
+ result: EntitlementsResponse = await self._client.request(get_spec(customer_id))
55
+ return result
56
+
57
+ async def check(self, customer_id: str, feature: str, *, quantity: int | None = None) -> EntitlementResponse:
58
+ """May this customer use ``feature`` (``quantity`` more units of it — "may they do 5 more?") right now?"""
59
+ result: EntitlementResponse = await self._client.request(check_spec(customer_id, feature, quantity))
60
+ return result