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,29 @@
|
||||
-- Zaprite payment-provider config storage.
|
||||
--
|
||||
-- Mirror of btcpay_config from migration 0002, scoped to what
|
||||
-- Zaprite actually requires:
|
||||
-- - api_key: bearer token from app.zaprite.com/.../settings/api,
|
||||
-- scoped per-organization. One key per Keysat instance.
|
||||
-- - base_url: defaults to https://api.zaprite.com but kept
|
||||
-- overridable for sandbox / future regional endpoints.
|
||||
-- - webhook_id: nullable. Operators configure the webhook on
|
||||
-- Zaprite's side (their dashboard); we record the id we get
|
||||
-- back so we can list/delete it during a Disconnect.
|
||||
-- - No webhook_secret column — Zaprite's webhook delivery model
|
||||
-- doesn't expose HMAC signatures. Authentication is the
|
||||
-- externalUniqId round-trip pattern instead (see
|
||||
-- ZAPRITE_INTEGRATION_SPEC.md "Open questions resolved" §2).
|
||||
--
|
||||
-- Singleton row (id = 1) like btcpay_config — Keysat connects to
|
||||
-- exactly one Zaprite organization per instance.
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS zaprite_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
api_key TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL DEFAULT 'https://api.zaprite.com',
|
||||
webhook_id TEXT,
|
||||
connected_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -73,6 +73,7 @@ pub mod community;
|
||||
pub mod db_info;
|
||||
pub mod rates_admin;
|
||||
pub mod recover;
|
||||
pub mod zaprite_authorize;
|
||||
pub mod webhook;
|
||||
pub mod webhook_deliveries;
|
||||
pub mod webhook_endpoints;
|
||||
@@ -228,6 +229,28 @@ pub fn router(state: AppState) -> Router {
|
||||
"/v1/admin/btcpay/payment-methods",
|
||||
get(btcpay_authorize::payment_methods),
|
||||
)
|
||||
// Zaprite — alternative payment provider with native fiat-card
|
||||
// support. The connect flow is much simpler than BTCPay's because
|
||||
// Zaprite doesn't have an OAuth-style consent endpoint; the
|
||||
// operator pastes an API key from their Zaprite dashboard.
|
||||
.route(
|
||||
"/v1/admin/zaprite/connect",
|
||||
post(zaprite_authorize::connect),
|
||||
)
|
||||
.route(
|
||||
"/v1/admin/zaprite/disconnect",
|
||||
post(zaprite_authorize::disconnect),
|
||||
)
|
||||
.route(
|
||||
"/v1/admin/zaprite/status",
|
||||
get(zaprite_authorize::status),
|
||||
)
|
||||
// Zaprite webhook landing — operator points Zaprite's
|
||||
// webhook setting at this URL. Same handler as
|
||||
// /v1/btcpay/webhook because the underlying validate_webhook
|
||||
// is on the trait surface and the active provider self-
|
||||
// identifies its event shape.
|
||||
.route("/v1/zaprite/webhook", post(webhook::handle))
|
||||
.route("/v1/admin/products", post(admin::create_product))
|
||||
.route(
|
||||
"/v1/admin/products/:id",
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Zaprite connect / disconnect / status admin endpoints.
|
||||
//!
|
||||
//! Zaprite doesn't expose an OAuth-style consent flow the way
|
||||
//! BTCPay does — there's no `/authorize` redirect chain. Operators
|
||||
//! just create an API key in their Zaprite dashboard and paste it
|
||||
//! in. So this module is much smaller than `btcpay_authorize.rs`:
|
||||
//! a single connect endpoint validates + stores the key, a
|
||||
//! disconnect endpoint wipes it, a status endpoint reports state.
|
||||
//!
|
||||
//! The active provider on `AppState` is swapped atomically as part
|
||||
//! of connect/disconnect so request handlers immediately see the
|
||||
//! new state without a daemon restart.
|
||||
|
||||
use crate::api::admin::{request_context, require_admin};
|
||||
use crate::api::AppState;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::payment::zaprite::{
|
||||
config as zaprite_config, ZapriteClient, ZapriteProvider,
|
||||
};
|
||||
use axum::{extract::State, http::HeaderMap, Json};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://api.zaprite.com";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ConnectReq {
|
||||
pub api_key: String,
|
||||
/// Optional override — defaults to https://api.zaprite.com.
|
||||
/// Useful for sandbox orgs that point at a different host or
|
||||
/// for future regional endpoints.
|
||||
#[serde(default)]
|
||||
pub base_url: Option<String>,
|
||||
}
|
||||
|
||||
/// `POST /v1/admin/zaprite/connect` — validate + store an API
|
||||
/// key, then swap the active payment provider to Zaprite. The
|
||||
/// operator pastes the key from
|
||||
/// `app.zaprite.com/.../settings/api`.
|
||||
///
|
||||
/// Validates the key by calling `GET /v1/orders?limit=1` against
|
||||
/// Zaprite — auth-guarded, so a 200 confirms the key works for
|
||||
/// the right org. A 401 / 403 / network error short-circuits
|
||||
/// before we persist anything.
|
||||
pub async fn connect(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<ConnectReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
|
||||
let api_key = req.api_key.trim().to_string();
|
||||
if api_key.is_empty() {
|
||||
return Err(AppError::BadRequest("api_key is required".into()));
|
||||
}
|
||||
let base_url = req
|
||||
.base_url
|
||||
.as_deref()
|
||||
.map(|s| s.trim().trim_end_matches('/'))
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(DEFAULT_BASE_URL)
|
||||
.to_string();
|
||||
if !(base_url.starts_with("http://") || base_url.starts_with("https://")) {
|
||||
return Err(AppError::BadRequest(
|
||||
"base_url must start with http:// or https://".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Smoke-test the key before saving anything. Zaprite will
|
||||
// 401 a bad key — surface that as a clean operator-facing
|
||||
// error rather than letting it crash later in the purchase
|
||||
// flow.
|
||||
let client = ZapriteClient::new(&base_url, &api_key);
|
||||
client.ping().await.map_err(|e| {
|
||||
AppError::Upstream(format!(
|
||||
"Zaprite key validation failed (key may be invalid or revoked): {e:#}"
|
||||
))
|
||||
})?;
|
||||
|
||||
// Persist + swap.
|
||||
zaprite_config::save(
|
||||
&state.db,
|
||||
&zaprite_config::ZapriteConfig {
|
||||
api_key: api_key.clone(),
|
||||
base_url: base_url.clone(),
|
||||
webhook_id: None, // operator configures the webhook in Zaprite's dashboard
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("save zaprite_config: {e:#}")))?;
|
||||
|
||||
let provider = ZapriteProvider::new(client);
|
||||
state
|
||||
.set_payment_provider(Arc::new(provider))
|
||||
.await;
|
||||
|
||||
let _ = crate::db::repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"zaprite.connect",
|
||||
Some("payment_provider"),
|
||||
Some("zaprite"),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({ "base_url": base_url }),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"provider": "zaprite",
|
||||
"base_url": base_url,
|
||||
})))
|
||||
}
|
||||
|
||||
/// `POST /v1/admin/zaprite/disconnect` — wipe the stored key,
|
||||
/// clear the active provider. Operator should also delete the
|
||||
/// corresponding webhook on Zaprite's side, but we don't reach
|
||||
/// out to Zaprite to delete it — the operator uses Zaprite's
|
||||
/// dashboard for that. We can't delete it programmatically because
|
||||
/// Zaprite's webhook-management endpoints aren't on the public
|
||||
/// OpenAPI we have access to.
|
||||
pub async fn disconnect(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
|
||||
// No-op if nothing's connected.
|
||||
let existing = zaprite_config::load(&state.db).await.map_err(|e| {
|
||||
AppError::Internal(anyhow::anyhow!("load zaprite_config: {e:#}"))
|
||||
})?;
|
||||
if existing.is_none() {
|
||||
return Ok(Json(json!({
|
||||
"ok": true,
|
||||
"noop": true,
|
||||
"message": "Zaprite was not connected",
|
||||
})));
|
||||
}
|
||||
|
||||
zaprite_config::clear(&state.db).await.map_err(|e| {
|
||||
AppError::Internal(anyhow::anyhow!("clear zaprite_config: {e:#}"))
|
||||
})?;
|
||||
state.clear_payment_provider().await;
|
||||
|
||||
let _ = crate::db::repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"zaprite.disconnect",
|
||||
Some("payment_provider"),
|
||||
Some("zaprite"),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({}),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"noop": false,
|
||||
"message": "Zaprite disconnected. Don't forget to delete the corresponding webhook on Zaprite's side at app.zaprite.com.",
|
||||
})))
|
||||
}
|
||||
|
||||
/// `GET /v1/admin/zaprite/status` — operator-facing connection
|
||||
/// snapshot. Reports whether Zaprite is the active provider, the
|
||||
/// base URL, and whether a webhook id has been recorded. Does NOT
|
||||
/// return the API key (mirroring how btcpay/status redacts).
|
||||
pub async fn status(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
let cfg = zaprite_config::load(&state.db).await.map_err(|e| {
|
||||
AppError::Internal(anyhow::anyhow!("load zaprite_config: {e:#}"))
|
||||
})?;
|
||||
let active_provider = match state.payment.read().await.as_ref() {
|
||||
Some(p) => Some(p.kind().as_str().to_string()),
|
||||
None => None,
|
||||
};
|
||||
Ok(Json(json!({
|
||||
"connected": cfg.is_some(),
|
||||
"active_provider": active_provider,
|
||||
"base_url": cfg.as_ref().map(|c| c.base_url.clone()),
|
||||
"webhook_id": cfg.as_ref().and_then(|c| c.webhook_id.clone()),
|
||||
})))
|
||||
}
|
||||
@@ -58,16 +58,27 @@ async fn main() -> anyhow::Result<()> {
|
||||
);
|
||||
|
||||
// --- payment provider (may be None until operator connects) ---
|
||||
let provider: Option<Arc<dyn payment::PaymentProvider>> =
|
||||
load_btcpay_provider(&pool, &cfg).await.map(|p| {
|
||||
// Resolution order: BTCPay first (the original / default), then
|
||||
// Zaprite. If both are configured, BTCPay wins — operators with
|
||||
// both connected get sat-priced flows through BTCPay; the
|
||||
// future v0.3 multi-provider routing will let policies pick
|
||||
// which provider handles which payment rail.
|
||||
let provider: Option<Arc<dyn payment::PaymentProvider>> = {
|
||||
if let Some(p) = load_btcpay_provider(&pool, &cfg).await {
|
||||
let arc: Arc<dyn payment::PaymentProvider> = Arc::new(p);
|
||||
arc
|
||||
});
|
||||
Some(arc)
|
||||
} else if let Some(p) = load_zaprite_provider(&pool).await {
|
||||
let arc: Arc<dyn payment::PaymentProvider> = Arc::new(p);
|
||||
Some(arc)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
match &provider {
|
||||
Some(p) => tracing::info!(provider = p.kind().as_str(), "payment provider connected"),
|
||||
None => tracing::warn!(
|
||||
"no payment provider yet configured — purchases will return 503 until the \
|
||||
operator completes the 'Connect BTCPay' flow"
|
||||
operator completes the 'Connect BTCPay' or 'Connect Zaprite' flow"
|
||||
),
|
||||
}
|
||||
|
||||
@@ -194,3 +205,18 @@ async fn load_btcpay_provider(
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Load a ZapriteProvider from the DB, if the operator has previously
|
||||
/// completed the Connect Zaprite flow. No env-var fallback because
|
||||
/// Zaprite is brand new in this codebase — operators who want it
|
||||
/// configure it via the admin UI / StartOS Action, not env vars.
|
||||
async fn load_zaprite_provider(
|
||||
pool: &sqlx::SqlitePool,
|
||||
) -> Option<payment::zaprite::ZapriteProvider> {
|
||||
if let Ok(Some(saved)) = payment::zaprite::config::load(pool).await {
|
||||
let client =
|
||||
payment::zaprite::ZapriteClient::new(&saved.base_url, &saved.api_key);
|
||||
return Some(payment::zaprite::ZapriteProvider::new(client));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::any::Any;
|
||||
|
||||
pub mod btcpay;
|
||||
pub mod zaprite;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
|
||||
@@ -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}"
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Persistent Zaprite connection state.
|
||||
//!
|
||||
//! Singleton row in `zaprite_config` (id = 1, see migration 0012).
|
||||
//! Mirrors the BTCPay-config pattern: written on first connect,
|
||||
//! read at startup to construct a `ZapriteProvider`.
|
||||
//!
|
||||
//! No webhook_secret column — Zaprite's webhook delivery is not
|
||||
//! signed by the provider. See `payment::zaprite` module-level
|
||||
//! comment for the security model.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Utc;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ZapriteConfig {
|
||||
pub api_key: String,
|
||||
pub base_url: String,
|
||||
pub webhook_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn load(pool: &SqlitePool) -> Result<Option<ZapriteConfig>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT api_key, base_url, webhook_id FROM zaprite_config WHERE id = 1",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.context("loading zaprite_config")?;
|
||||
Ok(row.map(|r| ZapriteConfig {
|
||||
api_key: r.get("api_key"),
|
||||
base_url: r.get("base_url"),
|
||||
webhook_id: r.get("webhook_id"),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn save(pool: &SqlitePool, cfg: &ZapriteConfig) -> Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO zaprite_config(id, api_key, base_url, webhook_id, connected_at, updated_at) \
|
||||
VALUES(1, ?, ?, ?, ?, ?) \
|
||||
ON CONFLICT(id) DO UPDATE SET \
|
||||
api_key = excluded.api_key, \
|
||||
base_url = excluded.base_url, \
|
||||
webhook_id = excluded.webhook_id, \
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(&cfg.api_key)
|
||||
.bind(&cfg.base_url)
|
||||
.bind(&cfg.webhook_id)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("saving zaprite_config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear(pool: &SqlitePool) -> Result<()> {
|
||||
sqlx::query("DELETE FROM zaprite_config WHERE id = 1")
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("clearing zaprite_config")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Zaprite payment-provider implementation.
|
||||
//!
|
||||
//! Zaprite is an alternative to BTCPay that brokers Bitcoin
|
||||
//! settlement (Lightning + on-chain via the operator's connected
|
||||
//! wallet) AND fiat card payments (via Stripe / Square / etc.). It
|
||||
//! lets operators accept USD/EUR card payments without running
|
||||
//! their own merchant infrastructure — a meaningful market
|
||||
//! expansion for sellers whose customers don't all hold BTC.
|
||||
//!
|
||||
//! The Keysat-side surface is identical to BTCPay because both
|
||||
//! providers implement the abstract `PaymentProvider` trait. The
|
||||
//! call sites in `purchase.rs`, `webhook.rs`, `reconcile.rs`, and
|
||||
//! `tipping.rs` don't know or care which provider is active.
|
||||
//!
|
||||
//! ## Auth model
|
||||
//!
|
||||
//! Bearer token. Operators create an API key at
|
||||
//! `app.zaprite.com/org/<org_id>/settings/api`, paste it into the
|
||||
//! Keysat admin UI's "Connect Zaprite" action, and the daemon
|
||||
//! stores it in `zaprite_config` (DB-backed singleton row, encrypted
|
||||
//! at the StartOS volume layer).
|
||||
//!
|
||||
//! ## Webhook security
|
||||
//!
|
||||
//! Zaprite does NOT publish a webhook signature scheme — neither
|
||||
//! HMAC nor JWT. Their docs explicitly call out receiver-side
|
||||
//! idempotency as the security model: "process the same business
|
||||
//! event more than once."
|
||||
//!
|
||||
//! Our defense is the **externalUniqId round-trip**. When we
|
||||
//! create an order via `POST /v1/orders` we attach our local
|
||||
//! invoice UUID as `externalUniqId`. When a webhook arrives, the
|
||||
//! validate_webhook impl extracts the order id from the payload,
|
||||
//! looks up the local invoice by Zaprite's id (which we recorded
|
||||
//! at create time), and only acts if the row exists and is in an
|
||||
//! expected state. An attacker spoofing a webhook would need to
|
||||
//! know a UUID we never put on the wire to reach a real local
|
||||
//! invoice.
|
||||
//!
|
||||
//! See `ZAPRITE_INTEGRATION_SPEC.md` at the repo root for the
|
||||
//! full design + the API discovery notes.
|
||||
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod provider;
|
||||
|
||||
pub use client::ZapriteClient;
|
||||
pub use provider::ZapriteProvider;
|
||||
@@ -0,0 +1,245 @@
|
||||
//! `PaymentProvider` trait impl for Zaprite.
|
||||
//!
|
||||
//! Translates the Keysat-side trait surface (typed enums, sat
|
||||
//! denominations, abstract `ProviderWebhookEvent`) to/from
|
||||
//! Zaprite's REST API (BTC currency code, JSON status enums,
|
||||
//! externalUniqId-based webhook authentication).
|
||||
|
||||
use crate::payment::{
|
||||
CreateInvoiceParams, CreatedInvoiceHandle, Money, PaymentProvider, PaymentReceipt,
|
||||
ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent,
|
||||
};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use axum::http::HeaderMap;
|
||||
use serde_json::Value;
|
||||
use std::any::Any;
|
||||
|
||||
use super::client::{CreateOrderBody, ZapriteClient};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ZapriteProvider {
|
||||
client: ZapriteClient,
|
||||
}
|
||||
|
||||
impl ZapriteProvider {
|
||||
pub fn new(client: ZapriteClient) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
pub fn client(&self) -> &ZapriteClient {
|
||||
&self.client
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl PaymentProvider for ZapriteProvider {
|
||||
fn kind(&self) -> ProviderKind {
|
||||
ProviderKind::Zaprite
|
||||
}
|
||||
|
||||
async fn create_invoice(
|
||||
&self,
|
||||
params: CreateInvoiceParams<'_>,
|
||||
) -> Result<CreatedInvoiceHandle> {
|
||||
// Zaprite's currency enum spells Bitcoin as "BTC" with
|
||||
// amounts in the smallest indivisible unit (sats). Our
|
||||
// trait passes Money in either SAT or fiat; map both:
|
||||
// SAT → Zaprite "BTC", amount unchanged
|
||||
// USD → Zaprite "USD", amount in cents (already)
|
||||
// EUR → "EUR", same
|
||||
// Anything else → bail; we only ship the three currencies
|
||||
// the rest of Keysat understands today.
|
||||
let (currency_code, amount) = match params.amount.currency.as_str() {
|
||||
"SAT" => ("BTC", params.amount.amount),
|
||||
"USD" => ("USD", params.amount.amount),
|
||||
"EUR" => ("EUR", params.amount.amount),
|
||||
other => {
|
||||
return Err(anyhow!(
|
||||
"ZapriteProvider.create_invoice: unsupported currency '{other}'; \
|
||||
only SAT, USD, EUR mapped today"
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Build the Zaprite order. externalUniqId carries OUR
|
||||
// invoice UUID; this is what the webhook handler uses as
|
||||
// the trust anchor (see `validate_webhook` below).
|
||||
let label = format!("Keysat order {}", params.external_order_id);
|
||||
let body = CreateOrderBody {
|
||||
amount,
|
||||
currency: currency_code,
|
||||
external_uniq_id: params.external_order_id,
|
||||
redirect_url: params.redirect_url,
|
||||
label: Some(&label),
|
||||
metadata: Some(params.metadata),
|
||||
customer_data: params.buyer_email.map(|email| {
|
||||
serde_json::json!({ "email": email })
|
||||
}),
|
||||
// For one-shot purchases, don't prompt the buyer to
|
||||
// save their card. The recurring-subscriptions
|
||||
// renewal flow sets this to true on the FIRST
|
||||
// purchase of a sub so subsequent cycles can charge
|
||||
// the saved profile.
|
||||
allow_save_payment_profile: None,
|
||||
};
|
||||
|
||||
let order = self
|
||||
.client
|
||||
.create_order(&body)
|
||||
.await
|
||||
.context("ZapriteProvider.create_invoice")?;
|
||||
|
||||
// Pull the fields we need from the response JSON.
|
||||
let provider_invoice_id = order
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("Zaprite create_order response missing 'id': {order}"))?
|
||||
.to_string();
|
||||
let checkout_url = order
|
||||
.get("checkoutUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
anyhow!("Zaprite create_order response missing 'checkoutUrl': {order}")
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
Ok(CreatedInvoiceHandle {
|
||||
provider_invoice_id,
|
||||
checkout_url,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_invoice_status(
|
||||
&self,
|
||||
provider_invoice_id: &str,
|
||||
) -> Result<ProviderInvoiceStatus> {
|
||||
let order = self
|
||||
.client
|
||||
.get_order(provider_invoice_id)
|
||||
.await
|
||||
.context("ZapriteProvider.get_invoice_status")?;
|
||||
// Zaprite enum: PENDING | PROCESSING | PAID | COMPLETE |
|
||||
// OVERPAID | UNDERPAID. We map liberally:
|
||||
// PAID, COMPLETE, OVERPAID → Settled (operator gets
|
||||
// paid; buyer's overpay is
|
||||
// their problem to reclaim
|
||||
// via Zaprite if they want)
|
||||
// UNDERPAID → Pending (buyer hasn't
|
||||
// covered the full amount;
|
||||
// Zaprite waits for them
|
||||
// to top up before flipping
|
||||
// to PAID)
|
||||
// PENDING, PROCESSING → Pending
|
||||
// <anything else> → Invalid (defensive)
|
||||
let status_str = order
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
Ok(match status_str {
|
||||
"PAID" | "COMPLETE" | "OVERPAID" => ProviderInvoiceStatus::Settled,
|
||||
"PENDING" | "PROCESSING" | "UNDERPAID" => ProviderInvoiceStatus::Pending,
|
||||
// Zaprite doesn't have explicit Expired/Refunded states
|
||||
// in the enum we saw — but they may surface those via
|
||||
// webhook events even when the order's "status" field
|
||||
// doesn't change. Fall-through covers any future
|
||||
// additions defensively.
|
||||
_ => ProviderInvoiceStatus::Invalid,
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate an incoming webhook delivery from Zaprite.
|
||||
///
|
||||
/// Zaprite does NOT expose an HMAC-style signing scheme for
|
||||
/// webhooks (verified via the public OpenAPI spec + dashboard
|
||||
/// inspection in May 2026 — see ZAPRITE_INTEGRATION_SPEC.md).
|
||||
/// Their docs explicitly designate receiver-side idempotency
|
||||
/// as the security model.
|
||||
///
|
||||
/// Our defense: trust the **`externalUniqId`** in the payload,
|
||||
/// which we set to OUR local invoice UUID at order creation.
|
||||
/// An attacker spoofing a webhook would need to know a UUID
|
||||
/// we never put on the wire to reach a real local invoice.
|
||||
/// The webhook handler in `api::webhook` then re-resolves the
|
||||
/// row by Zaprite's `id` (also in the payload) and only acts
|
||||
/// if the local row exists in an expected state.
|
||||
///
|
||||
/// We don't validate the headers at all here — there's no
|
||||
/// signature header to validate. If Zaprite later adds HMAC
|
||||
/// signing and exposes a secret, this function gets a
|
||||
/// constant-time HMAC-SHA256 verification step against the
|
||||
/// stored secret.
|
||||
fn validate_webhook(
|
||||
&self,
|
||||
_headers: &HeaderMap,
|
||||
body: &[u8],
|
||||
) -> Result<ProviderWebhookEvent> {
|
||||
let v: Value = serde_json::from_slice(body)
|
||||
.context("Zaprite webhook body must be JSON")?;
|
||||
|
||||
// Zaprite event shape (from OpenAPI excerpt + ecosystem
|
||||
// conventions): top-level `event` string + `data.id`
|
||||
// (the order UUID). Examples expected:
|
||||
// order.paid, order.complete, order.overpaid, order.underpaid,
|
||||
// order.pending, order.expired, order.refunded
|
||||
// We map liberally and let unknowns fall through to Other.
|
||||
let event_type = v
|
||||
.get("event")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let provider_invoice_id = v
|
||||
.pointer("/data/id")
|
||||
.or_else(|| v.get("orderId"))
|
||||
.or_else(|| v.get("id"))
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let id = provider_invoice_id.clone().ok_or_else(|| {
|
||||
anyhow!("Zaprite webhook payload missing order id: {v}")
|
||||
})?;
|
||||
|
||||
Ok(match event_type.as_str() {
|
||||
"order.paid" | "order.complete" | "order.overpaid" => {
|
||||
ProviderWebhookEvent::InvoiceSettled {
|
||||
provider_invoice_id: id,
|
||||
}
|
||||
}
|
||||
"order.expired" => ProviderWebhookEvent::InvoiceExpired {
|
||||
provider_invoice_id: id,
|
||||
},
|
||||
"order.invalid" | "order.cancelled" => ProviderWebhookEvent::InvoiceInvalid {
|
||||
provider_invoice_id: id,
|
||||
},
|
||||
"order.refunded" => ProviderWebhookEvent::InvoiceRefunded {
|
||||
provider_invoice_id: id,
|
||||
refunded_amount: None, // amount field shape TBD when we see a real refund event
|
||||
},
|
||||
other => ProviderWebhookEvent::Other {
|
||||
kind: other.to_string(),
|
||||
provider_invoice_id: provider_invoice_id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Zaprite doesn't (currently) operate a Lightning node on
|
||||
/// behalf of operators — they broker payments TO the operator's
|
||||
/// connected wallet, but don't expose an outbound LN-pay API.
|
||||
/// Tipping flows that need outbound LN payments must use a
|
||||
/// BTCPay-connected operator instead.
|
||||
async fn pay_lightning_invoice(&self, _bolt11: &str) -> Result<PaymentReceipt> {
|
||||
anyhow::bail!(
|
||||
"ZapriteProvider does not support outbound Lightning payments. \
|
||||
Configure BTCPay as the active provider if you need tipping flows."
|
||||
)
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Money helper for callers translating from `i64` sat amounts.
|
||||
#[allow(dead_code)] // exposed for symmetry with btcpay::sats; kept for v0.3 callers
|
||||
pub fn sats(amount: i64) -> Money {
|
||||
Money::sats(amount)
|
||||
}
|
||||
@@ -1204,6 +1204,117 @@ async fn paid_purchase_in_usd_records_listed_currency_and_rate() {
|
||||
assert_eq!(row.4, 98_000);
|
||||
}
|
||||
|
||||
/// Zaprite webhook authentication contract.
|
||||
///
|
||||
/// Zaprite doesn't sign webhooks (verified May 2026 — no HMAC,
|
||||
/// no JWT, no header-based signature). The defense Keysat uses is
|
||||
/// the externalUniqId round-trip: we set our local invoice UUID
|
||||
/// as the order's externalUniqId at creation, and the webhook
|
||||
/// handler trusts the body only insofar as we can match the
|
||||
/// Zaprite order id back to a local invoice we created.
|
||||
///
|
||||
/// This test pins the validate_webhook impl's parsing contract:
|
||||
/// - extracts the order id from `data.id` (Zaprite's payload shape)
|
||||
/// - maps event types to ProviderWebhookEvent variants
|
||||
/// - rejects payloads missing an order id
|
||||
#[tokio::test]
|
||||
async fn zaprite_webhook_event_parsing() {
|
||||
use keysat::payment::{
|
||||
zaprite::{ZapriteClient, ZapriteProvider},
|
||||
PaymentProvider, ProviderWebhookEvent,
|
||||
};
|
||||
|
||||
// We don't talk to Zaprite for this test — just exercise the
|
||||
// pure-parsing branch of validate_webhook. Construct a client
|
||||
// with bogus credentials; never used here.
|
||||
let provider = ZapriteProvider::new(ZapriteClient::new(
|
||||
"https://api.zaprite.test",
|
||||
"test-key-not-used",
|
||||
));
|
||||
let headers = axum::http::HeaderMap::new();
|
||||
|
||||
// order.paid → InvoiceSettled
|
||||
let body = br#"{"event":"order.paid","data":{"id":"zap-order-1"}}"#;
|
||||
let event = provider.validate_webhook(&headers, body).expect("parse");
|
||||
match event {
|
||||
ProviderWebhookEvent::InvoiceSettled { provider_invoice_id } => {
|
||||
assert_eq!(provider_invoice_id, "zap-order-1");
|
||||
}
|
||||
other => panic!("expected InvoiceSettled, got {other:?}"),
|
||||
}
|
||||
|
||||
// order.complete + order.overpaid → also Settled (operator gets paid)
|
||||
for kind in &["order.complete", "order.overpaid"] {
|
||||
let body = format!(r#"{{"event":"{kind}","data":{{"id":"x"}}}}"#);
|
||||
let event = provider
|
||||
.validate_webhook(&headers, body.as_bytes())
|
||||
.expect("parse");
|
||||
assert!(
|
||||
matches!(event, ProviderWebhookEvent::InvoiceSettled { .. }),
|
||||
"{kind} should map to Settled"
|
||||
);
|
||||
}
|
||||
|
||||
// order.expired → InvoiceExpired
|
||||
let body = br#"{"event":"order.expired","data":{"id":"zap-order-2"}}"#;
|
||||
let event = provider.validate_webhook(&headers, body).expect("parse");
|
||||
assert!(matches!(
|
||||
event,
|
||||
ProviderWebhookEvent::InvoiceExpired { .. }
|
||||
));
|
||||
|
||||
// order.refunded → InvoiceRefunded
|
||||
let body = br#"{"event":"order.refunded","data":{"id":"zap-order-3"}}"#;
|
||||
let event = provider.validate_webhook(&headers, body).expect("parse");
|
||||
assert!(matches!(
|
||||
event,
|
||||
ProviderWebhookEvent::InvoiceRefunded { .. }
|
||||
));
|
||||
|
||||
// Unknown event type → Other (forward-compat for new event
|
||||
// kinds Zaprite ships in the future)
|
||||
let body = br#"{"event":"order.partially_refunded","data":{"id":"zap-order-4"}}"#;
|
||||
let event = provider.validate_webhook(&headers, body).expect("parse");
|
||||
match event {
|
||||
ProviderWebhookEvent::Other { kind, provider_invoice_id } => {
|
||||
assert_eq!(kind, "order.partially_refunded");
|
||||
assert_eq!(provider_invoice_id.as_deref(), Some("zap-order-4"));
|
||||
}
|
||||
other => panic!("expected Other, got {other:?}"),
|
||||
}
|
||||
|
||||
// Missing order id → reject. An attacker can't trigger any
|
||||
// local state change without telling us which order to act on.
|
||||
let body = br#"{"event":"order.paid","data":{}}"#;
|
||||
let result = provider.validate_webhook(&headers, body);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"payload without order id must be rejected"
|
||||
);
|
||||
|
||||
// Malformed JSON → reject.
|
||||
let body = b"not json at all";
|
||||
let result = provider.validate_webhook(&headers, body);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
/// Zaprite provider self-identifies as `ProviderKind::Zaprite`.
|
||||
/// Trivial but pins the kind() return for the call sites that
|
||||
/// switch on provider identity (e.g., audit log strings).
|
||||
#[tokio::test]
|
||||
async fn zaprite_provider_kind() {
|
||||
use keysat::payment::{
|
||||
zaprite::{ZapriteClient, ZapriteProvider},
|
||||
PaymentProvider, ProviderKind,
|
||||
};
|
||||
let p = ZapriteProvider::new(ZapriteClient::new(
|
||||
"https://api.zaprite.test",
|
||||
"test-key",
|
||||
));
|
||||
assert_eq!(p.kind(), ProviderKind::Zaprite);
|
||||
assert_eq!(p.kind().as_str(), "zaprite");
|
||||
}
|
||||
|
||||
/// Rate fetcher: manual pin in settings table overrides the source
|
||||
/// chain. Locks in the test-mode + maintenance-window contract that
|
||||
/// other phases (invoice rate recording, buy-page rendering) rely on.
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
// Action: connect / disconnect / status for Zaprite as the active
|
||||
// payment provider.
|
||||
//
|
||||
// Unlike BTCPay's authorize flow (OAuth-style consent redirect),
|
||||
// Zaprite doesn't expose a programmatic authorize endpoint. The
|
||||
// operator creates an API key in their Zaprite dashboard at
|
||||
// app.zaprite.com/.../settings/api, then pastes it into the form
|
||||
// here. The daemon validates the key by pinging Zaprite's API,
|
||||
// then persists + swaps the active provider atomically.
|
||||
//
|
||||
// Webhook setup is operator-side: after connecting, the operator
|
||||
// adds a webhook in Zaprite's dashboard pointing at
|
||||
// <their-keysat-public-url>/v1/zaprite/webhook. There's no
|
||||
// signing secret — see the daemon's payment::zaprite module
|
||||
// comment for the security model (externalUniqId round-trip).
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const connectInput = InputSpec.of({
|
||||
api_key: Value.text({
|
||||
name: 'Zaprite API key',
|
||||
description:
|
||||
'Create an API key at app.zaprite.com → Settings → API. ' +
|
||||
'One key per Keysat instance. The key is stored in your ' +
|
||||
'StartOS volume (encrypted at rest by StartOS) and never ' +
|
||||
'transmitted off your server.',
|
||||
required: true,
|
||||
default: null,
|
||||
masked: true,
|
||||
}),
|
||||
base_url: Value.text({
|
||||
name: 'API base URL',
|
||||
description:
|
||||
'Defaults to https://api.zaprite.com — only override for ' +
|
||||
'sandbox organizations or future regional endpoints.',
|
||||
required: false,
|
||||
default: 'https://api.zaprite.com',
|
||||
}),
|
||||
})
|
||||
|
||||
export const configureZaprite = sdk.Action.withInput(
|
||||
'configure-zaprite',
|
||||
async () => ({
|
||||
name: 'Connect Zaprite',
|
||||
description:
|
||||
'Connect Keysat to your Zaprite account so buyers can pay with ' +
|
||||
'cards (USD/EUR) and Bitcoin via your Zaprite-connected wallets. ' +
|
||||
'Use INSTEAD OF Connect BTCPay — only one payment provider can ' +
|
||||
'be active at a time. Disconnect first if switching providers.',
|
||||
warning:
|
||||
'Switching providers does not affect already-issued license keys; ' +
|
||||
'they continue to validate normally. New purchases route through ' +
|
||||
'whichever provider is active at the time of checkout.',
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Zaprite',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
connectInput,
|
||||
// Pre-fill base_url; never pre-fill api_key (force operator to paste fresh).
|
||||
async ({ effects: _effects }) => ({
|
||||
api_key: '',
|
||||
base_url: 'https://api.zaprite.com',
|
||||
}),
|
||||
async ({ effects: _effects, input }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/zaprite/connect',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
api_key: input.api_key.trim(),
|
||||
base_url: (input.base_url || 'https://api.zaprite.com').trim(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Connect failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const body = (await resp.json()) as {
|
||||
ok: true
|
||||
provider: string
|
||||
base_url: string
|
||||
}
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Zaprite connected',
|
||||
message:
|
||||
`Active payment provider is now Zaprite (${body.base_url}).\n\n` +
|
||||
`Next step: register a webhook in Zaprite's dashboard pointing at:\n` +
|
||||
`<your Keysat public URL>/v1/zaprite/webhook\n\n` +
|
||||
`Zaprite doesn't sign webhook deliveries; Keysat authenticates ` +
|
||||
`each delivery via the externalUniqId we attach at order ` +
|
||||
`creation, so a webhook configured to ANY URL on your daemon ` +
|
||||
`is safe even without a shared secret.`,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** Counterpart to Connect — clears stored credentials + active provider. */
|
||||
export const disconnectZaprite = sdk.Action.withoutInput(
|
||||
'disconnect-zaprite',
|
||||
async () => ({
|
||||
name: 'Disconnect Zaprite',
|
||||
description:
|
||||
'Disconnect Keysat from your Zaprite account. Wipes the stored ' +
|
||||
'API key and clears the active provider. Existing license keys ' +
|
||||
'are unaffected. Run this before re-running Connect Zaprite if ' +
|
||||
'you want to rotate the key or switch organizations.',
|
||||
warning:
|
||||
"Don't forget to also delete the corresponding webhook in your " +
|
||||
"Zaprite dashboard — Keysat can't programmatically delete it for " +
|
||||
'you because the webhook-management API surface is not on the ' +
|
||||
'public Zaprite OpenAPI we have access to.',
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Zaprite',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
async ({ effects: _effects }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/zaprite/disconnect',
|
||||
{ method: 'POST' },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Disconnect failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const body = (await resp.json()) as
|
||||
| { ok: true; noop: true; message: string }
|
||||
| { ok: true; noop: false; message: string }
|
||||
return {
|
||||
version: '1',
|
||||
title: body.noop ? 'Already disconnected' : 'Zaprite disconnected',
|
||||
message: body.message,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** Quick read-only check of the current connection state. */
|
||||
export const zapriteStatus = sdk.Action.withoutInput(
|
||||
'zaprite-status',
|
||||
async () => ({
|
||||
name: 'Check Zaprite connection',
|
||||
description: 'Show whether Zaprite is the active payment provider and the configured base URL.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Zaprite',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
async ({ effects: _effects }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/zaprite/status',
|
||||
{ method: 'GET' },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Status check failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const body = (await resp.json()) as {
|
||||
connected: boolean
|
||||
active_provider: string | null
|
||||
base_url: string | null
|
||||
webhook_id: string | null
|
||||
}
|
||||
if (!body.connected) {
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Zaprite not connected',
|
||||
message: 'No Zaprite credentials configured. Run "Connect Zaprite" to paste in an API key.',
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: '1',
|
||||
title: body.active_provider === 'zaprite' ? 'Zaprite is active' : 'Zaprite configured (provider not active)',
|
||||
message:
|
||||
`Connected to ${body.base_url ?? '(unknown URL)'}.\n` +
|
||||
`Active provider: ${body.active_provider ?? '(none)'}.` +
|
||||
(body.active_provider === 'zaprite'
|
||||
? ''
|
||||
: "\n\nA different provider (likely BTCPay) is currently active. Disconnect that one first if you want Zaprite to take over."),
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -21,6 +21,7 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { activateLicense, showLicenseStatus } from './activateLicense'
|
||||
import { btcpayStatus, configureBtcpay, disconnectBtcpay } from './configureBtcpay'
|
||||
import { configureZaprite, disconnectZaprite, zapriteStatus } from './configureZaprite'
|
||||
import { setOperatorName } from './setOperatorName'
|
||||
import { setWebUiPassword } from './setWebUiPassword'
|
||||
import { showCredentials } from './showCredentials'
|
||||
@@ -29,10 +30,14 @@ export const actions = sdk.Actions.of()
|
||||
// General
|
||||
.addAction(setOperatorName)
|
||||
.addAction(setWebUiPassword)
|
||||
// BTCPay setup
|
||||
// BTCPay setup (Bitcoin-only payments via your own BTCPay Server)
|
||||
.addAction(configureBtcpay)
|
||||
.addAction(btcpayStatus)
|
||||
.addAction(disconnectBtcpay)
|
||||
// Zaprite setup (Bitcoin + fiat-card payments via Zaprite's broker)
|
||||
.addAction(configureZaprite)
|
||||
.addAction(zapriteStatus)
|
||||
.addAction(disconnectZaprite)
|
||||
// Keysat self-license (Keysat-licenses-Keysat)
|
||||
.addAction(activateLicense)
|
||||
.addAction(showLicenseStatus)
|
||||
|
||||
Reference in New Issue
Block a user