v0.2.0 — policy_slug on start_purchase + list_public_policies
Mirrors the TS SDK 0.2.0 + Rust SDK 0.2.0 changes for parity across
all four language clients.
StartPurchaseOptions gains `policy_slug: str | None`. New
`Client.list_public_policies(product_slug)` returns a
`PublicPoliciesResponse` carrying the buyer-visible tier list (slug,
name, price_sats, entitlements, recurring/trial flags, "Most popular"
flag). Same data the licensing service's /buy/<slug> page reads
server-side — public endpoint, no auth.
client.start_purchase("recap", StartPurchaseOptions(
policy_slug="pro",
buyer_email="buyer@example.com",
))
tiers = client.list_public_policies("recap")
for p in tiers.policies:
print(p.slug, p.name, p.price_sats, p.entitlements)
Backwards compatible — existing positional / kwarg callers continue
to work since policy_slug defaults to None.
This commit is contained in:
@@ -67,6 +67,9 @@ try:
|
||||
ValidateResponse,
|
||||
ValidateOptions,
|
||||
StartPurchaseOptions,
|
||||
PublicPolicy,
|
||||
PublicPoliciesProduct,
|
||||
PublicPoliciesResponse,
|
||||
PurchaseSession,
|
||||
PollResponse,
|
||||
RedeemFreeOptions,
|
||||
@@ -78,6 +81,9 @@ try:
|
||||
"ValidateResponse",
|
||||
"ValidateOptions",
|
||||
"StartPurchaseOptions",
|
||||
"PublicPolicy",
|
||||
"PublicPoliciesProduct",
|
||||
"PublicPoliciesResponse",
|
||||
"PurchaseSession",
|
||||
"PollResponse",
|
||||
"RedeemFreeOptions",
|
||||
@@ -87,4 +93,4 @@ except ImportError:
|
||||
# httpx not installed — that's fine, online client is optional.
|
||||
pass
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.0"
|
||||
|
||||
@@ -51,10 +51,56 @@ class ValidateOptions:
|
||||
|
||||
@dataclass
|
||||
class StartPurchaseOptions:
|
||||
"""Optional extras for :meth:`Client.start_purchase`.
|
||||
|
||||
All fields are optional. To buy a specific tier, set
|
||||
``policy_slug`` to one of the slugs returned by
|
||||
:meth:`Client.list_public_policies`. When omitted, the
|
||||
licensing service falls back to the product's default policy.
|
||||
"""
|
||||
|
||||
buyer_email: str | None = None
|
||||
buyer_note: str | None = None
|
||||
redirect_url: str | None = None
|
||||
code: str | None = None
|
||||
policy_slug: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PublicPolicy:
|
||||
"""One tier returned by :meth:`Client.list_public_policies`.
|
||||
|
||||
Mirrors what the licensing service's ``/buy/<slug>`` page reads
|
||||
server-side, so an in-app tier picker can render identical text
|
||||
and pricing.
|
||||
"""
|
||||
|
||||
slug: str
|
||||
name: str
|
||||
description: str
|
||||
price_sats: int
|
||||
duration_seconds: int
|
||||
max_machines: int
|
||||
is_trial: bool
|
||||
entitlements: list[str]
|
||||
highlighted: bool
|
||||
is_recurring: bool
|
||||
renewal_period_days: int
|
||||
trial_days: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class PublicPoliciesProduct:
|
||||
slug: str
|
||||
name: str
|
||||
description: str
|
||||
base_price_sats: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class PublicPoliciesResponse:
|
||||
product: PublicPoliciesProduct
|
||||
policies: list[PublicPolicy]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -205,6 +251,7 @@ class Client:
|
||||
"buyer_note": merged.buyer_note,
|
||||
"redirect_url": merged.redirect_url,
|
||||
"code": merged.code,
|
||||
"policy_slug": merged.policy_slug,
|
||||
}
|
||||
body = {k: v for k, v in body.items() if v is not None}
|
||||
raw = self._post("/v1/purchase", body)
|
||||
@@ -218,6 +265,44 @@ class Client:
|
||||
poll_url=raw["poll_url"],
|
||||
)
|
||||
|
||||
def list_public_policies(self, product_slug: str) -> PublicPoliciesResponse:
|
||||
"""List public, buyer-visible policies (tiers) for a product.
|
||||
|
||||
No auth required — same data the licensing service's
|
||||
``/buy/<slug>`` page reads server-side. Use this to render an
|
||||
in-app tier picker that stays in sync with the operator's
|
||||
admin-side tier setup. Internal fields (id, tip recipients,
|
||||
raw metadata) are omitted by the server.
|
||||
"""
|
||||
raw = self._get(f"/v1/products/{product_slug}/policies")
|
||||
product = raw.get("product", {}) or {}
|
||||
policies_raw = raw.get("policies") or []
|
||||
return PublicPoliciesResponse(
|
||||
product=PublicPoliciesProduct(
|
||||
slug=product.get("slug", ""),
|
||||
name=product.get("name", ""),
|
||||
description=product.get("description", "") or "",
|
||||
base_price_sats=int(product.get("base_price_sats", 0)),
|
||||
),
|
||||
policies=[
|
||||
PublicPolicy(
|
||||
slug=p.get("slug", ""),
|
||||
name=p.get("name", ""),
|
||||
description=p.get("description", "") or "",
|
||||
price_sats=int(p.get("price_sats", 0)),
|
||||
duration_seconds=int(p.get("duration_seconds", 0)),
|
||||
max_machines=int(p.get("max_machines", 1)),
|
||||
is_trial=bool(p.get("is_trial", False)),
|
||||
entitlements=list(p.get("entitlements") or []),
|
||||
highlighted=bool(p.get("highlighted", False)),
|
||||
is_recurring=bool(p.get("is_recurring", False)),
|
||||
renewal_period_days=int(p.get("renewal_period_days", 0)),
|
||||
trial_days=int(p.get("trial_days", 0)),
|
||||
)
|
||||
for p in policies_raw
|
||||
],
|
||||
)
|
||||
|
||||
def poll_purchase(self, invoice_id: str) -> PollResponse:
|
||||
raw = self._get(f"/v1/purchase/{invoice_id}")
|
||||
return PollResponse(
|
||||
|
||||
Reference in New Issue
Block a user