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]
|
[package]
|
||||||
name = "keysat-licensing-client"
|
name = "keysat-licensing-client"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.75"
|
rust-version = "1.75"
|
||||||
description = "Client library for Keysat. Verifies signed license keys offline and wraps the HTTP API for purchase and revocation checks."
|
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
|
## Purchase flow
|
||||||
|
|
||||||
```rust
|
```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
|
// open session.checkout_url in the user's browser
|
||||||
loop {
|
loop {
|
||||||
let poll = client.poll_purchase(&session.invoice_id).await?;
|
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 client = Client::new(&base_url)?;
|
||||||
|
|
||||||
let session = client
|
let session = client
|
||||||
.start_purchase(&product_slug, None, None)
|
.start_purchase(&product_slug, &Default::default())
|
||||||
.await?;
|
.await?;
|
||||||
println!("open the checkout in your browser:");
|
println!("open the checkout in your browser:");
|
||||||
println!(" {}", session.checkout_url);
|
println!(" {}", session.checkout_url);
|
||||||
|
|||||||
+2
-1
@@ -47,5 +47,6 @@ pub use verify::{Verifier, VerifyOk};
|
|||||||
|
|
||||||
#[cfg(feature = "online")]
|
#[cfg(feature = "online")]
|
||||||
pub use 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
|
/// Start a purchase for `product_slug`. Returns the BTCPay checkout URL
|
||||||
/// to open in the buyer's browser and the invoice id to poll.
|
/// 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(
|
pub async fn start_purchase(
|
||||||
&self,
|
&self,
|
||||||
product_slug: &str,
|
product_slug: &str,
|
||||||
buyer_email: Option<&str>,
|
options: &StartPurchaseOptions<'_>,
|
||||||
redirect_url: Option<&str>,
|
|
||||||
) -> Result<PurchaseSession> {
|
) -> Result<PurchaseSession> {
|
||||||
let url = self
|
let url = self
|
||||||
.base
|
.base
|
||||||
@@ -179,12 +182,21 @@ impl Client {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
buyer_email: Option<&'a str>,
|
buyer_email: Option<&'a str>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
buyer_note: Option<&'a str>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
redirect_url: Option<&'a str>,
|
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 {
|
let body = Req {
|
||||||
product: product_slug,
|
product: product_slug,
|
||||||
buyer_email,
|
buyer_email: options.buyer_email,
|
||||||
redirect_url,
|
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 resp = self.http.post(url).json(&body).send().await?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
@@ -198,6 +210,33 @@ impl Client {
|
|||||||
serde_json::from_str(&text).map_err(|e| Error::Other(e.to_string()))
|
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,
|
/// Poll a purchase by its invoice id. Returns the current status and,
|
||||||
/// once the invoice has settled, the signed `license_key` string.
|
/// once the invoice has settled, the signed `license_key` string.
|
||||||
pub async fn poll_purchase(&self, invoice_id: &str) -> Result<PollResponse> {
|
pub async fn poll_purchase(&self, invoice_id: &str) -> Result<PollResponse> {
|
||||||
@@ -320,6 +359,98 @@ pub struct MachineResponse {
|
|||||||
pub max_machines: Option<i64>,
|
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.
|
/// Response from `/v1/purchase` when starting a purchase.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct PurchaseSession {
|
pub struct PurchaseSession {
|
||||||
|
|||||||
Reference in New Issue
Block a user