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:
Keysat
2026-05-09 09:08:51 -05:00
parent 2d9caa814e
commit 94654f6526
3 changed files with 93 additions and 2 deletions
+7 -1
View File
@@ -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"
+85
View File
@@ -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(