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
+23
View File
@@ -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",