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:
@@ -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()),
|
||||
})))
|
||||
}
|
||||
Reference in New Issue
Block a user