4cde540b60
Six routine bumps land together, all driven by end-to-end sandbox testing
of the Zaprite recurring auto-charge path that shipped in :45:
:46 Provider create-invoice failures now surface the underlying cause.
Switched user-facing format from `{e}` to `{e:#}` so the full anyhow
chain reaches the buy page; added `tracing::error!` for symmetric
daemon-log visibility. Without this, failed checkouts showed only
"ZapriteProvider.create_invoice" with no clue what actually went wrong.
:47 Zaprite recurring purchases now create the contact upfront. Sandbox
surfaced that `allowSavePaymentProfile: true` requires an explicit
`contactId` on the order — passing only `customerData: { email }`
returns 400. Added `client.create_contact(email, name)` + threaded
the returned id as `contactId`. Graceful degradation: recurring +
no buyer_email → one-shot mode with a warn log; renewals fall back
to manual-pay.
:48 Thank-you page copy is now provider-aware. The wait-page lede
hardcoded "Your Bitcoin payment was received" + Lightning/on-chain
timing — wrong for Zaprite card payments. Reads SETTING_ACTIVE_PROVIDER
and branches the copy + the JS polling-status text accordingly.
:49 Zaprite saved-profile capture: full diagnostic logging + reconciler
path. Discovered five recurring subscriptions settled successfully
but with NULL `zaprite_payment_profile_id`. Root cause: capture
hook had six silent early-return paths, AND the reconciler (which
catches missed webhooks) never called `on_invoice_settled` so subs
created via that path never got their profile captured. Added warn
logs on every early-return + wired capture into `reconcile.rs`'s
post-license-issuance flow.
:50 Webhook event-type extraction probes multiple field names. Confirmed
deliveries were arriving but all logged as "non-actionable event_type=
" — Zaprite doesn't use the convention-suggested `event` field. Now
probes `event` / `eventType` / `type` / `name`, first non-empty wins.
Also widened the order-id probe to include `data.object.id`. On a
miss, warn-logs the raw payload truncated to 2KB so the actual field
name can be added to the probe list.
:51 Zaprite `order.change` event is now actionable. The :50 probe-fix
surfaced that Zaprite's primary delivery shape is a generic
`order.change` event that just says "something about this order
changed" — the receiver has to look at `/data/status` to figure out
what actually changed. They do NOT send the convention-suggested
`order.paid` / `order.complete` events. Added an `order.change`
match arm that branches on status (PAID/COMPLETE/OVERPAID →
InvoiceSettled, EXPIRED → InvoiceExpired, INVALID/CANCELLED →
InvoiceInvalid, in-flight states → Other). End result: webhook-
driven settles now flip subscriptions within seconds of Zaprite's
callback instead of waiting ~45s for the reconciler.
Net effect of the batch: the recurring auto-charge flow is now validated
end-to-end against the Zaprite sandbox. Buyers paying with a card via
Stripe-backed Zaprite trigger contact + saved-profile creation, the
webhook fires `order.change` with status PAID, Keysat captures the
saved-profile id within seconds, and the renewal worker is wired to
auto-charge subsequent cycles. Manual-pay fallback intact for buyers
who decline save-card or pay via Bitcoin/Lightning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
269 lines
10 KiB
Rust
269 lines
10 KiB
Rust
//! 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<Value>,
|
|
/// `{ 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<Value>,
|
|
/// `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<bool>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
impl ZapriteClient {
|
|
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>) -> 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<HeaderMap> {
|
|
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<Value> {
|
|
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<Value> {
|
|
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<Value> {
|
|
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<Value> {
|
|
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<Value> {
|
|
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}"
|
|
))
|
|
}
|
|
}
|