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