v0.2.0 — policy_slug on start_purchase + list_public_policies
Mirrors the TS SDK 0.2.0 changes (cf c3a57a0 in keysat-client-ts) so
all four language clients have parity on the tiered-purchase surface.
Breaking change on start_purchase: positional `(buyer_email,
redirect_url)` args replaced with a `&StartPurchaseOptions` struct.
Migration is mechanical:
// before
client.start_purchase(slug, None, None).await?;
// after
client.start_purchase(slug, &Default::default()).await?;
// tier-aware
client.start_purchase(slug, &StartPurchaseOptions {
policy_slug: Some("pro"),
buyer_email: Some("buyer@example.com"),
..Default::default()
}).await?;
The struct has fields for buyer_email, buyer_note, redirect_url,
code, and the new policy_slug. New `list_public_policies` method
fetches the buyer-visible tier list (no auth) so an in-app tier
picker can render dynamically.
Lib + tests build clean; the example's anyhow-not-in-deps issue is
pre-existing and unrelated.
This commit is contained in:
Generated
+1677
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "keysat-licensing-client"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
description = "Client library for Keysat. Verifies signed license keys offline and wraps the HTTP API for purchase and revocation checks."
|
||||
|
||||
@@ -51,7 +51,18 @@ The server enforces revocation live and does trust-on-first-use fingerprint bind
|
||||
## Purchase flow
|
||||
|
||||
```rust
|
||||
let session = client.start_purchase("my-product", None, None).await?;
|
||||
use licensing_client::StartPurchaseOptions;
|
||||
|
||||
// Default tier:
|
||||
let session = client.start_purchase("my-product", &Default::default()).await?;
|
||||
|
||||
// Specific tier (e.g. Pro):
|
||||
let session = client.start_purchase("my-product", &StartPurchaseOptions {
|
||||
policy_slug: Some("pro"),
|
||||
buyer_email: Some("buyer@example.com"),
|
||||
..Default::default()
|
||||
}).await?;
|
||||
|
||||
// open session.checkout_url in the user's browser
|
||||
loop {
|
||||
let poll = client.poll_purchase(&session.invoice_id).await?;
|
||||
|
||||
@@ -19,7 +19,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let client = Client::new(&base_url)?;
|
||||
|
||||
let session = client
|
||||
.start_purchase(&product_slug, None, None)
|
||||
.start_purchase(&product_slug, &Default::default())
|
||||
.await?;
|
||||
println!("open the checkout in your browser:");
|
||||
println!(" {}", session.checkout_url);
|
||||
|
||||
+2
-1
@@ -47,5 +47,6 @@ pub use verify::{Verifier, VerifyOk};
|
||||
|
||||
#[cfg(feature = "online")]
|
||||
pub use online::{
|
||||
Client, MachineResponse, PollResponse, PurchaseSession, ValidateRequest, ValidateResponse,
|
||||
Client, MachineResponse, PollResponse, PublicPoliciesProduct, PublicPoliciesResponse,
|
||||
PublicPolicy, PurchaseSession, StartPurchaseOptions, ValidateRequest, ValidateResponse,
|
||||
};
|
||||
|
||||
+135
-4
@@ -162,11 +162,14 @@ impl Client {
|
||||
|
||||
/// Start a purchase for `product_slug`. Returns the BTCPay checkout URL
|
||||
/// to open in the buyer's browser and the invoice id to poll.
|
||||
///
|
||||
/// Use [`StartPurchaseOptions`] to pass tier (`policy_slug`), buyer
|
||||
/// email, redirect URL, discount code, and buyer note. For the simple
|
||||
/// "default tier, no extras" case, pass `&Default::default()`.
|
||||
pub async fn start_purchase(
|
||||
&self,
|
||||
product_slug: &str,
|
||||
buyer_email: Option<&str>,
|
||||
redirect_url: Option<&str>,
|
||||
options: &StartPurchaseOptions<'_>,
|
||||
) -> Result<PurchaseSession> {
|
||||
let url = self
|
||||
.base
|
||||
@@ -179,12 +182,21 @@ impl Client {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
buyer_email: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
buyer_note: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
redirect_url: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
code: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
policy_slug: Option<&'a str>,
|
||||
}
|
||||
let body = Req {
|
||||
product: product_slug,
|
||||
buyer_email,
|
||||
redirect_url,
|
||||
buyer_email: options.buyer_email,
|
||||
buyer_note: options.buyer_note,
|
||||
redirect_url: options.redirect_url,
|
||||
code: options.code,
|
||||
policy_slug: options.policy_slug,
|
||||
};
|
||||
let resp = self.http.post(url).json(&body).send().await?;
|
||||
let status = resp.status();
|
||||
@@ -198,6 +210,33 @@ impl Client {
|
||||
serde_json::from_str(&text).map_err(|e| Error::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// List public, buyer-visible policies (tiers) for a product. No
|
||||
/// auth required — same data the licensing service's `/buy/<slug>`
|
||||
/// page uses 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
|
||||
/// deliberately omitted by the server.
|
||||
pub async fn list_public_policies(
|
||||
&self,
|
||||
product_slug: &str,
|
||||
) -> Result<PublicPoliciesResponse> {
|
||||
let url = self
|
||||
.base
|
||||
.join(&format!("/v1/products/{product_slug}/policies"))
|
||||
.map_err(|e| Error::BadUrl(e.to_string()))?;
|
||||
let resp = self.http.get(url).send().await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await?;
|
||||
if !status.is_success() {
|
||||
return Err(Error::Server {
|
||||
status: status.as_u16(),
|
||||
body: text,
|
||||
});
|
||||
}
|
||||
serde_json::from_str(&text).map_err(|e| Error::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// Poll a purchase by its invoice id. Returns the current status and,
|
||||
/// once the invoice has settled, the signed `license_key` string.
|
||||
pub async fn poll_purchase(&self, invoice_id: &str) -> Result<PollResponse> {
|
||||
@@ -320,6 +359,98 @@ pub struct MachineResponse {
|
||||
pub max_machines: Option<i64>,
|
||||
}
|
||||
|
||||
/// Optional extras for [`Client::start_purchase`]. All fields are
|
||||
/// optional; pass `&Default::default()` for the simple "default
|
||||
/// tier, no extras" case. To buy a specific tier, set
|
||||
/// `policy_slug = Some("...")`. To list available tiers without
|
||||
/// auth, use [`Client::list_public_policies`].
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StartPurchaseOptions<'a> {
|
||||
/// Email for the receipt.
|
||||
pub buyer_email: Option<&'a str>,
|
||||
/// Free-form note recorded on the invoice (admin-visible).
|
||||
pub buyer_note: Option<&'a str>,
|
||||
/// URL the buyer should be returned to after payment.
|
||||
pub redirect_url: Option<&'a str>,
|
||||
/// Discount / referral code.
|
||||
pub code: Option<&'a str>,
|
||||
/// Tier slug. When set, the policy's `price_sats_override`
|
||||
/// becomes the base price and the issued license carries the
|
||||
/// policy's entitlements / duration / max_machines / trial flag.
|
||||
pub policy_slug: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// One tier on the buyer-facing tier picker. Returned by
|
||||
/// [`Client::list_public_policies`]. Mirrors what `/buy/<slug>`
|
||||
/// renders server-side, so an in-app tier picker can show
|
||||
/// identical text and pricing.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct PublicPolicy {
|
||||
/// Stable identifier for the tier (e.g. `"core"`, `"pro"`).
|
||||
/// Pass to [`StartPurchaseOptions::policy_slug`] to buy this tier.
|
||||
pub slug: String,
|
||||
/// Operator-set display name (e.g. `"Pro"`).
|
||||
pub name: String,
|
||||
/// Free-form per-tier blurb. Empty when not set.
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Effective price in the smallest unit of the product's listed
|
||||
/// currency: sats for SAT-priced products, cents for USD/EUR.
|
||||
pub price_sats: i64,
|
||||
/// 0 = perpetual; otherwise license lifetime in seconds.
|
||||
#[serde(default)]
|
||||
pub duration_seconds: i64,
|
||||
/// Seat cap. 0 = unlimited, 1 = single-seat, n = n-seat.
|
||||
#[serde(default = "default_one")]
|
||||
pub max_machines: i64,
|
||||
/// True if this tier is flagged as a trial.
|
||||
#[serde(default)]
|
||||
pub is_trial: bool,
|
||||
/// Entitlement slugs the issued license will carry.
|
||||
#[serde(default)]
|
||||
pub entitlements: Vec<String>,
|
||||
/// True if the operator marked this tier "Most popular".
|
||||
#[serde(default)]
|
||||
pub highlighted: bool,
|
||||
/// True if the policy is a recurring subscription.
|
||||
#[serde(default)]
|
||||
pub is_recurring: bool,
|
||||
/// Renewal cadence in days (0 for non-recurring).
|
||||
#[serde(default)]
|
||||
pub renewal_period_days: i64,
|
||||
/// First-cycle free-trial length (0 for none).
|
||||
#[serde(default)]
|
||||
pub trial_days: i64,
|
||||
}
|
||||
|
||||
fn default_one() -> i64 {
|
||||
1
|
||||
}
|
||||
|
||||
/// Product-level fields included in the public-policies response.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct PublicPoliciesProduct {
|
||||
/// Product slug.
|
||||
pub slug: String,
|
||||
/// Operator-set product display name.
|
||||
pub name: String,
|
||||
/// Operator-set product description (free-form). Empty when unset.
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Product's base price in sats. Tiers may override via
|
||||
/// [`PublicPolicy::price_sats`].
|
||||
pub base_price_sats: i64,
|
||||
}
|
||||
|
||||
/// Response from `/v1/products/<slug>/policies`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct PublicPoliciesResponse {
|
||||
/// Product the tiers belong to.
|
||||
pub product: PublicPoliciesProduct,
|
||||
/// Active, public tiers — sorted by ascending effective price.
|
||||
pub policies: Vec<PublicPolicy>,
|
||||
}
|
||||
|
||||
/// Response from `/v1/purchase` when starting a purchase.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct PurchaseSession {
|
||||
|
||||
Reference in New Issue
Block a user