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