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:
Grant
2026-05-08 16:34:58 -05:00
parent 4251e96082
commit 9eba309a8f
12 changed files with 1130 additions and 6 deletions
@@ -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()),
})))
}