//! Thin HTTP client for Zaprite's `/v1/*` API. //! //! Maps directly to the OpenAPI spec at api.zaprite.com/openapi.json. //! Returns the raw JSON shapes for now — the `ZapriteProvider` impl //! turns them into the trait's typed enums. use anyhow::{anyhow, Context, Result}; use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE}; use serde::Serialize; use serde_json::Value; use std::time::Duration; #[derive(Debug, Clone)] pub struct ZapriteClient { pub base_url: String, pub api_key: String, http: reqwest::Client, } /// Subset of `POST /v1/orders` request body — the fields Keysat /// actually populates. Zaprite accepts many more (invoice line /// items, contacts, etc.) that we don't need for the licensing /// flow. #[derive(Debug, Serialize)] pub struct CreateOrderBody<'a> { pub amount: i64, pub currency: &'a str, /// OUR internal invoice UUID. The webhook handler uses this /// as the trust anchor — only orders Zaprite reports back /// with a matching externalUniqId are honored. Zaprite does /// NOT dedupe on this field; it's reconciliation only. #[serde(rename = "externalUniqId")] pub external_uniq_id: &'a str, /// URL we send the buyer to after Zaprite finishes the /// checkout (success or otherwise). Zaprite appends its own /// status fragments. #[serde(rename = "redirectUrl")] pub redirect_url: &'a str, /// Display label on Zaprite's checkout page + on Bitcoin /// transaction labels. #[serde(skip_serializing_if = "Option::is_none")] pub label: Option<&'a str>, /// Free-form metadata Keysat round-trips for audit. #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, /// `{ email, name }` — set if the buyer provided one at /// checkout. Zaprite uses this for receipts. #[serde(rename = "customerData", skip_serializing_if = "Option::is_none")] pub customer_data: Option, /// `true` allows the buyer to save their card on Zaprite for /// recurring charges. Set when the policy is recurring. #[serde(rename = "allowSavePaymentProfile", skip_serializing_if = "Option::is_none")] pub allow_save_payment_profile: Option, /// Zaprite contact id to attach this order to. REQUIRED by /// Zaprite when `allow_save_payment_profile` is true — without /// it the create-order call returns /// `400 contactId is required when allowSavePaymentProfile is true`. /// Optional otherwise; passing it for one-shot purchases just /// associates the order with a known contact in the operator's /// Zaprite dashboard. #[serde(rename = "contactId", skip_serializing_if = "Option::is_none")] pub contact_id: Option, } impl ZapriteClient { pub fn new(base_url: impl Into, api_key: impl Into) -> Self { let base_url = base_url.into().trim_end_matches('/').to_string(); let http = reqwest::Client::builder() .timeout(Duration::from_secs(15)) .build() .expect("build reqwest client"); Self { base_url, api_key: api_key.into(), http, } } fn auth_headers(&self) -> Result { let mut h = HeaderMap::new(); h.insert( AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {}", self.api_key)) .map_err(|e| anyhow!("invalid bearer token: {e}"))?, ); h.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); Ok(h) } /// `POST /v1/orders` — create an order. Returns the full order /// JSON so the caller can pull whichever fields it needs /// (`id`, `checkoutUrl`, `status`, etc.). pub async fn create_order(&self, body: &CreateOrderBody<'_>) -> Result { let url = format!("{}/v1/orders", self.base_url); let resp = self .http .post(&url) .headers(self.auth_headers()?) .json(body) .send() .await .context("Zaprite create_order request")?; let status = resp.status(); let raw = resp.text().await.context("read create_order body")?; if !status.is_success() { return Err(anyhow!( "Zaprite create_order returned HTTP {status}: {raw}" )); } serde_json::from_str(&raw).context("parse create_order response") } /// `GET /v1/orders/{id}` — fetch an order by Zaprite id OR by /// externalUniqId (Zaprite accepts either). Used by the /// reconcile loop to catch missed webhooks. pub async fn get_order(&self, order_id: &str) -> Result { let encoded = urlencoding::encode(order_id); let url = format!("{}/v1/orders/{encoded}", self.base_url); let resp = self .http .get(&url) .headers(self.auth_headers()?) .send() .await .context("Zaprite get_order request")?; let status = resp.status(); let raw = resp.text().await.context("read get_order body")?; if !status.is_success() { return Err(anyhow!( "Zaprite get_order({order_id}) returned HTTP {status}: {raw}" )); } serde_json::from_str(&raw).context("parse get_order response") } /// `POST /v1/orders/charge` — charge an order against a /// previously-saved payment profile. Used by the recurring- /// subscriptions renewal worker (per the /// RECURRING_SUBSCRIPTIONS_DESIGN.md "Phase 2 — Renewal worker" /// section). Not invoked from one-shot purchase flow. pub async fn charge_order_with_profile( &self, order_id: &str, payment_profile_id: &str, ) -> Result { let url = format!("{}/v1/orders/charge", self.base_url); let body = serde_json::json!({ "orderId": order_id, "paymentProfileId": payment_profile_id, }); let resp = self .http .post(&url) .headers(self.auth_headers()?) .json(&body) .send() .await .context("Zaprite charge_order_with_profile request")?; let status = resp.status(); let raw = resp.text().await.context("read charge body")?; if !status.is_success() { return Err(anyhow!( "Zaprite charge_order_with_profile returned HTTP {status}: {raw}" )); } serde_json::from_str(&raw).context("parse charge response") } /// `POST /v1/contacts` — create a Zaprite contact. Required /// upstream step before creating an order with /// `allowSavePaymentProfile: true` (Zaprite needs to know which /// contact the saved profile attaches to). Returns the full /// contact JSON; the caller extracts `id` to pass as /// `contactId` on the subsequent order create. /// /// `legal_name` is required by Zaprite's schema; we fall back to /// the email itself when the buyer didn't supply a name. The /// operator can rename the contact in the Zaprite dashboard if /// they care about display polish. /// /// NOTE on duplicates: Zaprite's duplicate-email behavior on /// `POST /v1/contacts` is undocumented (their llms.txt explicitly /// says "Not documented"). Empirically we accept whatever Zaprite /// does — if they create a duplicate, the operator's Zaprite /// contact list gets a row per recurring purchase from the same /// buyer. The multi-provider work (planned `:47+`) will introduce /// a Keysat-side `zaprite_contacts` cache keyed on (email, /// provider_id) to dedup upfront. For sandbox testing + early /// production this is acceptable noise. pub async fn create_contact( &self, email: &str, name: Option<&str>, ) -> Result { let legal_name = name.unwrap_or(email); let url = format!("{}/v1/contacts", self.base_url); let body = serde_json::json!({ "email": email, "legalName": legal_name, }); let resp = self .http .post(&url) .headers(self.auth_headers()?) .json(&body) .send() .await .context("Zaprite create_contact request")?; let status = resp.status(); let raw = resp.text().await.context("read create_contact body")?; if !status.is_success() { return Err(anyhow!( "Zaprite create_contact returned HTTP {status}: {raw}" )); } serde_json::from_str(&raw).context("parse create_contact response") } /// `GET /v1/contacts/{id}` — fetch a Zaprite contact, which /// includes the `paymentProfiles[]` array we mine for the /// saved-card id after a recurring first-cycle settle. Each /// profile has `id`, `method`, `expiresAt`, and a `sourceOrder` /// nested object whose `externalUniqId` is the invoice UUID we /// passed when creating the order — that's how we identify the /// profile the buyer just saved on the order that triggered /// this lookup. pub async fn get_contact(&self, contact_id: &str) -> Result { let encoded = urlencoding::encode(contact_id); let url = format!("{}/v1/contacts/{encoded}", self.base_url); let resp = self .http .get(&url) .headers(self.auth_headers()?) .send() .await .context("Zaprite get_contact request")?; let status = resp.status(); let raw = resp.text().await.context("read get_contact body")?; if !status.is_success() { return Err(anyhow!( "Zaprite get_contact({contact_id}) returned HTTP {status}: {raw}" )); } serde_json::from_str(&raw).context("parse get_contact response") } /// Smoke test for Connect-flow validation. Pings `GET /v1/orders` /// (the list endpoint) — auth-guarded, so a 200 confirms the /// API key works against the right org. pub async fn ping(&self) -> Result<()> { let url = format!("{}/v1/orders?limit=1", self.base_url); let resp = self .http .get(&url) .headers(self.auth_headers()?) .send() .await .context("Zaprite ping request")?; let status = resp.status(); if status.is_success() { return Ok(()); } let body = resp.text().await.unwrap_or_default(); Err(anyhow!( "Zaprite ping returned HTTP {status}: {body}" )) } }