v0.2.0:2 — Zaprite payment provider + recurring subscriptions schema foundation
This release adds Zaprite as an alternative to BTCPay. Operators can now choose between two payment rails: - BTCPay: Bitcoin-only, you run the BTCPay Server yourself - Zaprite: Bitcoin + fiat cards (USD/EUR via Stripe/Square), brokered by Zaprite, settles to your connected wallets Only one is active at a time per Keysat instance. Switching requires Disconnect → Connect; existing license keys are unaffected. Future v0.3 work routes per-policy choice (e.g., "free tier via Zaprite, paid tier via BTCPay") if operators want both, but for v0.2.0:2 it's either-or. What's in this release: **Migration 0011 — recurring subscriptions schema (dormant).** Adds `subscriptions` and `subscription_invoices` tables, plus `is_recurring`/`renewal_period_days`/`grace_period_days` (default 7)/ `trial_days` (default 0) on policies. No daemon code uses these yet — phases 2-6 of RECURRING_SUBSCRIPTIONS_DESIGN.md land in follow-up commits. Migration regression test covers the additive contract against populated data. **Migration 0012 — zaprite_config.** Singleton-row table for the operator's Zaprite API key + base URL + recorded webhook id. Mirrors btcpay_config from migration 0002. **ZapriteProvider implementation.** New module at src/payment/zaprite/ with client.rs (HTTP, Bearer auth), config.rs (DB persistence), provider.rs (PaymentProvider trait impl). Maps Zaprite's currency enum (BTC/USD/EUR) to/from the Money type; maps Zaprite's order status enum (PENDING/PROCESSING/PAID/COMPLETE/ OVERPAID/UNDERPAID) to ProviderInvoiceStatus. **Webhook security via externalUniqId round-trip.** Zaprite does NOT publish a webhook signature scheme (verified May 2026 against public OpenAPI + dashboard). Their docs explicitly designate receiver-side idempotency as the security model. Keysat's defense: attach our local invoice UUID as externalUniqId at order creation, then trust the webhook only insofar as the order id resolves to a local invoice in an expected state. Documented in detail in the payment::zaprite module-level comment + the validate_webhook docstring. **Admin endpoints.** - POST /v1/admin/zaprite/connect: validates the API key by pinging GET /v1/orders before persisting; swaps active provider atomically - POST /v1/admin/zaprite/disconnect: clears stored creds + provider - GET /v1/admin/zaprite/status: read-only connection snapshot - POST /v1/zaprite/webhook: webhook landing route (alias of the existing /v1/btcpay/webhook handler since validate_webhook is trait-level) **StartOS Actions** under a new "Zaprite" group: Connect Zaprite, Check Zaprite connection, Disconnect Zaprite. Operator pastes the API key into a masked input; daemon validates + saves. **Tests.** Two new in tests/api.rs (zaprite_webhook_event_parsing covers the full event-type mapping + missing-id rejection + malformed-JSON rejection; zaprite_provider_kind pins the identification). Migration regression test for 0011. Test count grows 39 → 41. Operators on BTCPay see no change. Operators wanting Zaprite go through the StartOS Actions tab → Connect Zaprite, paste their API key, register a webhook in Zaprite's dashboard pointing at their public Keysat URL + /v1/zaprite/webhook. Recurring subscriptions are NOT yet operator-visible — schema only in this release. Daemon-code that uses the subscriptions tables (renewal worker, validate-hot-path subscription branch, admin UI) lands in subsequent commits per the design doc's phased plan.
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
//! 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>,
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
/// 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}"
|
||||
))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user